├── .credo.exs ├── .formatter.exs ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── coveralls.json ├── lib ├── oauth2.ex └── oauth2 │ ├── access_token.ex │ ├── client.ex │ ├── error.ex │ ├── request.ex │ ├── response.ex │ ├── serializer.ex │ ├── strategy.ex │ ├── strategy │ ├── auth_code.ex │ ├── client_credentials.ex │ ├── password.ex │ └── refresh.ex │ └── util.ex ├── mix.exs ├── mix.lock ├── priv └── plts │ └── .gitkeep └── test ├── oauth2 ├── access_token_test.exs ├── client_test.exs ├── error_test.exs ├── response_test.exs ├── strategy │ ├── auth_code_test.exs │ ├── client_credentials_test.exs │ ├── password_test.exs │ └── refresh_test.exs └── util_test.exs ├── oauth2_test.exs ├── support └── test_helpers.ex └── 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/", "test/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: true, 41 | # 42 | # If you want to use uncolored output by default, you can change `color` 43 | # to `false` below: 44 | # 45 | color: true, 46 | # 47 | # You can customize the parameters of any check by adding a second element 48 | # to the tuple. 49 | # 50 | # To disable a check put `false` as second element: 51 | # 52 | # {Credo.Check.Design.DuplicatedCode, false} 53 | # 54 | checks: [ 55 | # 56 | ## Consistency Checks 57 | # 58 | {Credo.Check.Consistency.ExceptionNames, []}, 59 | {Credo.Check.Consistency.LineEndings, []}, 60 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 61 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 62 | {Credo.Check.Consistency.SpaceInParentheses, []}, 63 | {Credo.Check.Consistency.TabsOrSpaces, []}, 64 | 65 | # 66 | ## Design Checks 67 | # 68 | # You can customize the priority of any check 69 | # Priority values are: `low, normal, high, higher` 70 | # 71 | {Credo.Check.Design.AliasUsage, 72 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 73 | # You can also customize the exit_status of each check. 74 | # If you don't want TODO comments to cause `mix credo` to fail, just 75 | # set this value to 0 (zero). 76 | # 77 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 78 | {Credo.Check.Design.TagFIXME, []}, 79 | 80 | # 81 | ## Readability Checks 82 | # 83 | {Credo.Check.Readability.AliasOrder, []}, 84 | {Credo.Check.Readability.FunctionNames, []}, 85 | {Credo.Check.Readability.LargeNumbers, []}, 86 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 87 | {Credo.Check.Readability.ModuleAttributeNames, []}, 88 | {Credo.Check.Readability.ModuleDoc, []}, 89 | {Credo.Check.Readability.ModuleNames, []}, 90 | {Credo.Check.Readability.ParenthesesInCondition, []}, 91 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 92 | {Credo.Check.Readability.PredicateFunctionNames, []}, 93 | {Credo.Check.Readability.PreferImplicitTry, []}, 94 | {Credo.Check.Readability.RedundantBlankLines, []}, 95 | {Credo.Check.Readability.Semicolons, []}, 96 | {Credo.Check.Readability.SpaceAfterCommas, []}, 97 | {Credo.Check.Readability.StringSigils, []}, 98 | {Credo.Check.Readability.TrailingBlankLine, []}, 99 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 100 | # TODO: enable by default in Credo 1.1 101 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 102 | {Credo.Check.Readability.VariableNames, []}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.CondStatements, []}, 108 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 109 | {Credo.Check.Refactor.FunctionArity, []}, 110 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 111 | {Credo.Check.Refactor.MapInto, false}, 112 | {Credo.Check.Refactor.MatchInCondition, []}, 113 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 114 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 115 | {Credo.Check.Refactor.Nesting, []}, 116 | {Credo.Check.Refactor.UnlessWithElse, []}, 117 | {Credo.Check.Refactor.WithClauses, []}, 118 | 119 | # 120 | ## Warnings 121 | # 122 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 123 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 124 | {Credo.Check.Warning.IExPry, []}, 125 | {Credo.Check.Warning.IoInspect, []}, 126 | {Credo.Check.Warning.LazyLogging, false}, 127 | {Credo.Check.Warning.OperationOnSameValues, []}, 128 | {Credo.Check.Warning.OperationWithConstantResult, []}, 129 | {Credo.Check.Warning.RaiseInsideRescue, []}, 130 | {Credo.Check.Warning.UnusedEnumOperation, []}, 131 | {Credo.Check.Warning.UnusedFileOperation, []}, 132 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 133 | {Credo.Check.Warning.UnusedListOperation, []}, 134 | {Credo.Check.Warning.UnusedPathOperation, []}, 135 | {Credo.Check.Warning.UnusedRegexOperation, []}, 136 | {Credo.Check.Warning.UnusedStringOperation, []}, 137 | {Credo.Check.Warning.UnusedTupleOperation, []}, 138 | 139 | # 140 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 141 | # 142 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 143 | {Credo.Check.Design.DuplicatedCode, false}, 144 | {Credo.Check.Readability.MultiAlias, false}, 145 | {Credo.Check.Readability.Specs, false}, 146 | {Credo.Check.Readability.SinglePipe, false}, 147 | {Credo.Check.Refactor.ABCSize, false}, 148 | {Credo.Check.Refactor.AppendSingleItem, false}, 149 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 150 | {Credo.Check.Refactor.ModuleDependencies, false}, 151 | {Credo.Check.Refactor.PipeChainStart, false}, 152 | {Credo.Check.Refactor.VariableRebinding, false}, 153 | {Credo.Check.Warning.MapGetUnsafePass, false}, 154 | {Credo.Check.Warning.UnsafeToAtom, false} 155 | 156 | # 157 | # Custom checks can be created using `mix credo.gen.check`. 158 | # 159 | ] 160 | } 161 | ] 162 | } 163 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern takes the most precedence. 2 | # Default owners for everything in the repo. 3 | * @ueberauth/developers 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | qa: 13 | uses: straw-hat-team/github-actions-workflows/.github/workflows/elixir-quality-assurance.yml@v1.6.3 14 | with: 15 | elixir-version: '1.14' 16 | otp-version: '24.3' 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | hex-publish: 9 | uses: straw-hat-team/github-actions-workflows/.github/workflows/elixir-hex-publish.yml@v1.6.3 10 | with: 11 | elixir-version: '1.14' 12 | otp-version: '24.3' 13 | secrets: 14 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 15 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '30 1 * * *' 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v8 18 | with: 19 | days-before-issue-stale: 30 20 | days-before-issue-close: 15 21 | days-before-pr-stale: 60 22 | days-before-pr-close: 60 23 | 24 | stale-issue-label: 'stale:discard' 25 | exempt-issue-labels: 'stale:keep' 26 | stale-issue-message: > 27 | This issue has been automatically marked as "stale:discard". **If this issue still relevant, please leave 28 | any comment** (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to 29 | prioritize it yet. 30 | If you have any new additional information, please include it with your comment. 31 | close-issue-message: > 32 | Closing this issue after a prolonged period of inactivity. If this issue is still relevant, feel free to 33 | re-open the issue. Thank you! 34 | 35 | stale-pr-label: 'stale:discard' 36 | exempt-pr-labels: 'stale:keep' 37 | stale-pr-message: > 38 | This pull request has been automatically marked as "stale:discard". **If this pull request is still 39 | relevant, please leave any comment** (for example, "bump"), and we'll keep it open. We are sorry that we 40 | haven't been able to prioritize reviewing it yet. 41 | Your contribution is very much appreciated!. 42 | close-pr-message: > 43 | Closing this pull request after a prolonged period of inactivity. If this issue is still relevant, please 44 | ask for this pull request to be reopened. Thank you! 45 | -------------------------------------------------------------------------------- /.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 third-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 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | oauth2-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp/ 27 | 28 | /priv/plts/*.plt 29 | /priv/plts/*.plt.hash 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.0 (2022-11-29) 4 | 5 | ### Improvements 6 | 7 | - Now you can have a lot more control over your http client, including 8 | selecting what client you are using from the adapters available for 9 | [Tesla](https://github.com/elixir-tesla/tesla). 10 | You can also easily add logging and tracing with middleware. 11 | 12 | ### Backward Incompatible Changes 13 | 14 | - No longer directly using hackney it's still possible to use it through a 15 | Tesla adapter. To keep all your tweaks working correctly you'll need to 16 | add these settings: 17 | 18 | In mix.exs 19 | ```elixir 20 | # mix.exs 21 | defp deps do 22 | # Add the dependency 23 | [ 24 | {:oauth2, "~> 2.0"}, 25 | {:hackney, "~> 1.18"} # This is the new line you need to add 26 | ] 27 | end 28 | ``` 29 | 30 | In config: 31 | ```elixir 32 | config :oauth2, adapter: Tesla.Adapter.Hackney 33 | ``` 34 | 35 | ## v2.0.1 (2022-06-20) 36 | 37 | ### Bug fixes 38 | 39 | - Fix incorrect Accept header when requesting token 40 | 41 | ## v2.0.0 (2019-07-15) 42 | 43 | ### Bug fixes (possibly backwards incompatible) 44 | 45 | - Ensure that the OAuth client is authenticated via Authorization header as 46 | described in the spec (#131). 47 | 48 | ## v1.0.1 (2019-04-12) 49 | 50 | ### Bug fixes 51 | 52 | - Always use the provided serializer if one is registered (#132) 53 | 54 | ## v1.0.0 (2019-03-13) 55 | 56 | ### Backward Incompatible Changes 57 | 58 | - There is no longer a default serializer for `application/json`. Please make 59 | sure to register a serializer with `OAuth2.Client.put_serializer/3`. 60 | - Serializers are now registered via `OAuth2.Client.put_serializer/3`. 61 | This change allows applications wrapping `oauth2` a way to provide default 62 | serializers without requiring the user to manually configure a serializer. 63 | 64 | ## v0.9.4 (2018-10-18) 65 | 66 | ### Improvements 67 | 68 | - Relaxed `hackney` version requirements 69 | 70 | ## v0.9.3 (2018-08-13) 71 | 72 | ### Bug fixes 73 | 74 | - Various type specs fixed 75 | 76 | ## v0.9.2 (2017-11-17) 77 | 78 | ### Bug fixes 79 | 80 | - Updates the `OAuth2.Client.get_token!` function to handle error `OAuth2.Response` structs. 81 | 82 | ## v0.9.1 (2017-03-10) 83 | 84 | ### Improvements 85 | 86 | - Fix dialyzer warnings. 87 | - Update `hackney` to `1.7` 88 | 89 | ### Bug fixes 90 | 91 | - De-dupe headers. 92 | 93 | ## v0.9.0 (2017-02-02) 94 | 95 | ### Improvements 96 | 97 | - Remove deprecated usage of `Behaviour` and `defcallback` 98 | - Provides better support for configuring `request_opts` that will be used on 99 | every request. This is useful for configuring SSL options, etc. 100 | - Provides support for `hackney`s streaming of responses. 101 | - Better warnings when a serializer isn't properly configured. 102 | 103 | ### Backward Incompatible Changes 104 | 105 | - Responses with status codes between `400..599` will now return `{:error, %OAuth2.Response{}}` instead of `{:ok, %OAuth2.Response{}}` 106 | - When using the `!` versions of functions, `{:error, %OAuth2.Response{}}` will 107 | be converted to an `%OAuth2.Error{}` and raised. 108 | 109 | ## v0.8.3 (2017-01-26) 110 | 111 | - Fix compile-time warnings for Elixir 1.4 112 | - Fix dialyzer warnings on `@type params` 113 | - Fix `content-type` resolving when there are multiple params 114 | - Return the same refresh token unless a new one is provided 115 | - Raise an exception when missing serializer configuration 116 | 117 | ## v0.8.2 (2016-11-22) 118 | 119 | ### Bug Fixes 120 | 121 | - Fixed an issue in handling non-standard `expires` key in access token 122 | requests. 123 | 124 | ## v0.8.1 (2016-11-18) 125 | 126 | ### Improvements 127 | 128 | - Added the ability to debug responses from the provider. 129 | 130 | ### Bug Fixes 131 | 132 | - Fixed regression in handling `text/plain` content-type for tokens in #74 133 | 134 | ## v0.8.0 (2016-10-05) 135 | 136 | ### Improvements 137 | 138 | - Added `OAuth2.Client.basic_auth/1` convenience function. 139 | 140 | ### Bug Fixes 141 | 142 | - Fixed broken `RefreshToken` strategy reported in #66 143 | - Fixed an issue where checking the `content-type` was defaulting to 144 | `application/json` causing Poison to explode. 145 | 146 | ## v0.7.0 (2016-08-16) 147 | 148 | ### Improvements 149 | 150 | - Add support for custom serializers based on MIME types. 151 | - Remove dependency on `HTTPoison` in favor of using `hackney` directly. 152 | - Remove dependency on `mimetype_parser`. 153 | - `Poison` is now only a `test` dependency. 154 | 155 | ### Bug Fixes 156 | 157 | - `expires_in` values that are returned as strings are now properly parsed into integers for `expires_at`. 158 | 159 | ### Backward Incompatible Changes 160 | 161 | Prior to version `v0.7.0` `OAuth2.Client` was primarily used for the purpose 162 | of interfacing with the OAuth server to retrieve a token. `OAuth2.Token` was 163 | then responsible for using that token to make authenticated requests. 164 | 165 | In `v0.7.0` this interface has been refactored so that an `OAuth2.Client` struct 166 | now references an `OAuth2.Token` directly and many of the action methods have 167 | been moved so that they are called on `OAuth2.Client`, with an instance of the 168 | client struct as their first argument. 169 | 170 | Please consult the [README](https://github.com/scrogson/oauth2/blob/v0.7.0/README.md) for an example of general usage to retrieve a token and make a request. 171 | 172 | The following methods have been moved and adjusted so that they take a `OAuth2.Client.t` which contains a token, rather than a token directly: 173 | 174 | - `OAuth2.AccessToken.get` -> `OAuth2.Client.get` 175 | - `OAuth2.AccessToken.get!` -> `OAuth2.Client.get!` 176 | - `OAuth2.AccessToken.put` -> `OAuth2.Client.put` 177 | - `OAuth2.AccessToken.put!` -> `OAuth2.Client.put!` 178 | - `OAuth2.AccessToken.patch` -> `OAuth2.Client.patch` 179 | - `OAuth2.AccessToken.patch!` -> `OAuth2.Client.patch!` 180 | - `OAuth2.AccessToken.post` -> `OAuth2.Client.post` 181 | - `OAuth2.AccessToken.post!` -> `OAuth2.Client.post!` 182 | - `OAuth2.AccessToken.delete` -> `OAuth2.Client.delete` 183 | - `OAuth2.AccessToken.delete!` -> `OAuth2.Client.delete!` 184 | - `OAuth2.AccessToken.refresh` -> `OAuth2.Client.refresh_token` 185 | - `OAuth2.AccessToken.refresh!` -> `OAuth2.Client.refresh_token!` 186 | 187 | Additionally, the following methods have been moved to `OAuth2.Request` 188 | 189 | - `OAuth2.AccessToken.request` -> `OAuth2.Request.request` 190 | - `OAuth2.AccessToken.request!` -> `OAuth2.Request.request!` 191 | 192 | Diff: https://github.com/scrogson/oauth2/compare/v0.6.0...v0.7.0 193 | 194 | ## v0.6.0 (2016-06-24) 195 | 196 | ### Improvements 197 | 198 | - Use Poison ~> 2.0 199 | - Reset client headers after fetching the token 200 | 201 | ### Bug Fixes 202 | 203 | - Fix up auth code flow to match the RFC 204 | 205 | Diff: https://github.com/scrogson/oauth2/compare/v0.5.0...v0.6.0 206 | 207 | ## v0.5.0 (2015-11-03) 208 | 209 | ### Improvements 210 | 211 | - You can now request a refresh token with `OAuth2.AccessToken.refresh`. The `!` alternative is also available. 212 | - Added `Bypass` for improved testability. 213 | - `Plug` is no longer a direct dependency. It is only included as a test dependency through the `Bypass` library. 214 | - `OAuth2.AccessToken` now supports `DELETE` requests with `delete` and `delete!` 215 | - More tests! 216 | 217 | ### Bug Fixes 218 | 219 | - Params are no longer sent in both the body and as a query string for `POST` requests with `OAuth2.Client.get_token` 220 | - Responses will no longer be parsed automatically if the `content-type` is not supported by this lib. Registering custom parsers is a future goal for this library. 221 | - Errors are now properly raised when they occur. 222 | 223 | ### Backwards Incompatible Changes 224 | 225 | - `OAuth2.new/1` has been removed. Use `OAuth2.Client.new/1` instead. 226 | 227 | Diff: https://github.com/scrogson/oauth2/compare/v0.4.0...v0.5.0 228 | 229 | ## v0.4.0 (2015-10-27) 230 | 231 | ### Additions/Improvements 232 | 233 | - `OAuth2.AccessToken` now supports: `post`, `post!`, `put`, `put!`, `patch`, and `patch!`. 234 | - Better documentation 235 | - Test coverage improved 236 | 237 | ### Bug fixes 238 | 239 | - Empty response bodies are no longer decoded 240 | 241 | ### Breaking changes 242 | 243 | - `OAuth2.AccessToken.get!/4` now returns `OAuth2.Response{}` instead of just the parsed body. 244 | 245 | ### Acknowledgments 246 | 247 | Thanks to @meatherly, @dejanstrbac, and @optikfluffel for their contributions! 248 | 249 | Diff: https://github.com/scrogson/oauth2/compare/v0.3.0...v0.4.0 250 | 251 | ## v0.3.0 (2015-08-19) 252 | 253 | Bump `Plug` dependency to `1.0`. 254 | 255 | Diff: https://github.com/scrogson/oauth2/compare/v0.2.0...v0.3.0 256 | 257 | ## v0.2.0 (2015-07-13) 258 | 259 | - `:erlang.now` was replaced with `:os.timestamp` for compatibility with Erlang 18 260 | - You can now pass options to the `HTTPoison` library with `OAuth2.Client.get_token/4` and `OAuth2.Client.get_token!/4` 261 | 262 | Diff: https://github.com/scrogson/oauth2/compare/v0.1.1...v0.2.0 263 | 264 | ## v0.1.1 (2015-04-18) 265 | 266 | - Remove compilation warnings. 267 | - Fix `request_body` function for `ClientCredentials` 268 | 269 | Diff: https://github.com/scrogson/oauth2/compare/v0.1.0...v0.1.1 270 | 271 | ## v0.1.0 (2015-04-14) 272 | 273 | This release bring breaking changes and more documentation. 274 | 275 | Please see the [README](https://github.com/scrogson/oauth2/blob/v0.1.0/README.md) or [Hex Docs](http://hexdocs.pm/oauth2/0.1.0) for more details. 276 | 277 | Diff: https://github.com/scrogson/oauth2/compare/v0.0.5...v0.1.0 278 | 279 | ## v0.0.5 (2015-04-11) 280 | 281 | - Handles Facebook `expires` key for Access Tokens. 282 | - Ensure the token type defaults to 'Bearer' when it is not present. 283 | 284 | Diff: https://github.com/scrogson/oauth2/compare/0.0.3...v0.0.5 285 | 286 | ## v0.0.3 (2015-01-12) 287 | 288 | - Relax version requirements for Poison. 289 | 290 | ## v0.0.2 (2015-01-10) 291 | 292 | This release brings Password and Client Credentials strategies. 293 | 294 | http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.3 295 | http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.4 296 | 297 | ## v0.0.1 (2014-12-07) 298 | 299 | Initial release. 300 | 301 | This initial release includes a functional authorization code strategy: http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Sonny Scroggin 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 | # OAuth2 (Client) 2 | 3 | [![Build Status](https://github.com/ueberauth/oauth2/actions/workflows/ci.yml/badge.svg)](https://github.com/ueberauth/oauth2/actions/workflows/ci.yml) 4 | [![Coverage Status](https://coveralls.io/repos/scrogson/oauth2/badge.svg?branch=master&service=github)](https://coveralls.io/github/scrogson/oauth2?branch=master) 5 | [![Module Version](https://img.shields.io/hexpm/v/oauth2.svg)](https://hex.pm/packages/oauth2) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/oauth2/) 7 | [![Total Download](https://img.shields.io/hexpm/dt/oauth2.svg)](https://hex.pm/packages/oauth2) 8 | [![License](https://img.shields.io/hexpm/l/oauth2.svg)](https://github.com/scrogson/oauth2/blob/master/LICENSE) 9 | [![Last Updated](https://img.shields.io/github/last-commit/scrogson/oauth2.svg)](https://github.com/scrogson/oauth2/commits/master) 10 | 11 | An Elixir [OAuth](https://en.wikipedia.org/wiki/OAuth) 2.0 Client Library. 12 | 13 | ## Install 14 | 15 | ```elixir 16 | # mix.exs 17 | 18 | defp deps do 19 | # Add the dependency 20 | [ 21 | {:oauth2, "~> 2.0"}, 22 | {:hackney, "~> 1.18"} # depending on what tesla adapter you use 23 | ] 24 | end 25 | ``` 26 | 27 | ## Configure a serializer 28 | 29 | This library can be configured to handle encoding and decoding requests and 30 | responses automatically based on the `accept` and/or `content-type` headers. 31 | 32 | If you need to handle various MIME types, you can simply register serializers like so: 33 | 34 | ```elixir 35 | OAuth2.Client.put_serializer(client, "application/vnd.api+json", Jason) 36 | OAuth2.Client.put_serializer(client, "application/xml", MyApp.Parsers.XML) 37 | ``` 38 | 39 | The modules are expected to export `encode!/1` and `decode!/1`. 40 | 41 | ```elixir 42 | defmodule MyApp.Parsers.XML do 43 | def encode!(data), do: # ... 44 | def decode!(binary), do: # ... 45 | end 46 | ``` 47 | 48 | Please see the documentation for [OAuth2.Serializer](https://hexdocs.pm/oauth2/OAuth2.Serializer.html) 49 | for more details. 50 | 51 | ## Configure a http client 52 | 53 | The http client library used is [tesla](https://github.com/elixir-tesla/tesla), the default adapter is 54 | Httpc, since it comes out of the box with every Erlang instance but you can easily change it to something 55 | better. 56 | You can configure another adaptor like this: 57 | 58 | ```elixir 59 | config :oauth2, adapter: Tesla.Adapter.Mint 60 | ``` 61 | 62 | You can also add your own tesla middleware: 63 | 64 | ```elixir 65 | config :oauth2, middleware: [ 66 | Tesla.Middleware.Retry, 67 | {Tesla.Middleware.Fuse, name: :example} 68 | ] 69 | ``` 70 | 71 | ## Debug mode 72 | 73 | Sometimes it's handy to see what's coming back from the response when getting 74 | a token. You can configure OAuth2 to output the response like so: 75 | 76 | ```elixir 77 | config :oauth2, debug: true 78 | ``` 79 | 80 | ## Usage 81 | 82 | Current implemented strategies: 83 | 84 | - Authorization Code 85 | - Password 86 | - Client Credentials 87 | 88 | ### Authorization Code Flow (AuthCode Strategy) 89 | 90 | ```elixir 91 | # Initialize a client with client_id, client_secret, site, and redirect_uri. 92 | # The strategy option is optional as it defaults to `OAuth2.Strategy.AuthCode`. 93 | 94 | client = OAuth2.Client.new([ 95 | strategy: OAuth2.Strategy.AuthCode, #default 96 | client_id: "client_id", 97 | client_secret: "abc123", 98 | site: "https://auth.example.com", 99 | redirect_uri: "https://example.com/auth/callback" 100 | ]) 101 | 102 | # Generate the authorization URL and redirect the user to the provider. 103 | OAuth2.Client.authorize_url!(client) 104 | # => "https://auth.example.com/oauth/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback&response_type=code" 105 | 106 | # Use the authorization code returned from the provider to obtain an access token. 107 | client = OAuth2.Client.get_token!(client, code: "someauthcode") 108 | 109 | # Use the access token to make a request for resources 110 | resource = OAuth2.Client.get!(client, "/api/resource").body 111 | ``` 112 | 113 | ### Client Credentials Flow 114 | 115 | Getting an initial access token: 116 | 117 | ```elixir 118 | # Initializing a client with the strategy `OAuth2.Strategy.ClientCredentials` 119 | 120 | client = OAuth2.Client.new([ 121 | strategy: OAuth2.Strategy.ClientCredentials, 122 | client_id: "client_id", 123 | client_secret: "abc123", 124 | site: "https://auth.example.com" 125 | ]) 126 | 127 | # Request a token from with the newly created client 128 | # Token will be stored inside the `%OAuth2.Client{}` struct (client.token) 129 | client = OAuth2.Client.get_token!(client) 130 | 131 | # client.token contains the `%OAuth2.AccessToken{}` struct 132 | 133 | # raw access token 134 | access_token = client.token.access_token 135 | ``` 136 | 137 | Refreshing an access token: 138 | 139 | ```elixir 140 | # raw refresh token - use a client with `OAuth2.Strategy.Refresh` for refreshing the token 141 | refresh_token = client.token.refresh_token 142 | 143 | refresh_client = OAuth2.Client.new([ 144 | strategy: OAuth2.Strategy.Refresh, 145 | client_id: "client_id", 146 | client_secret: "abc123", 147 | site: "https://auth.example.com", 148 | params: %{"refresh_token" => refresh_token} 149 | ]) 150 | 151 | # refresh_client.token contains the `%OAuth2.AccessToken{}` struct again 152 | refresh_client = OAuth2.Client.get_token!(refresh_client) 153 | ``` 154 | 155 | ## Write Your Own Strategy 156 | 157 | Here's an example strategy for GitHub: 158 | 159 | ```elixir 160 | defmodule GitHub do 161 | use OAuth2.Strategy 162 | 163 | # Public API 164 | 165 | def client do 166 | OAuth2.Client.new([ 167 | strategy: __MODULE__, 168 | client_id: System.get_env("GITHUB_CLIENT_ID"), 169 | client_secret: System.get_env("GITHUB_CLIENT_SECRET"), 170 | redirect_uri: "http://myapp.com/auth/callback", 171 | site: "https://api.github.com", 172 | authorize_url: "https://github.com/login/oauth/authorize", 173 | token_url: "https://github.com/login/oauth/access_token" 174 | ]) 175 | |> OAuth2.Client.put_serializer("application/json", Jason) 176 | end 177 | 178 | def authorize_url! do 179 | OAuth2.Client.authorize_url!(client(), scope: "user,public_repo") 180 | end 181 | 182 | # you can pass options to the underlying http library via `opts` parameter 183 | def get_token!(params \\ [], headers \\ [], opts \\ []) do 184 | OAuth2.Client.get_token!(client(), params, headers, opts) 185 | end 186 | 187 | # Strategy Callbacks 188 | 189 | def authorize_url(client, params) do 190 | OAuth2.Strategy.AuthCode.authorize_url(client, params) 191 | end 192 | 193 | def get_token(client, params, headers) do 194 | client 195 | |> put_header("accept", "application/json") 196 | |> OAuth2.Strategy.AuthCode.get_token(params, headers) 197 | end 198 | end 199 | ``` 200 | 201 | Here's how you'd use the example GitHub strategy: 202 | 203 | Generate the authorize URL and redirect the client for authorization. 204 | 205 | ```elixir 206 | GitHub.authorize_url! 207 | ``` 208 | 209 | Capture the `code` in your callback route on your server and use it to obtain an access token. 210 | 211 | ```elixir 212 | client = GitHub.get_token!(code: code) 213 | ``` 214 | 215 | Use the access token to access desired resources. 216 | 217 | ```elixir 218 | user = OAuth2.Client.get!(client, "/user").body 219 | 220 | # Or 221 | case OAuth2.Client.get(client, "/user") do 222 | {:ok, %OAuth2.Response{body: user}} -> 223 | user 224 | {:error, %OAuth2.Response{status_code: 401, body: body}} -> 225 | Logger.error("Unauthorized token") 226 | {:error, %OAuth2.Error{reason: reason}} -> 227 | Logger.error("Error: #{inspect reason}") 228 | end 229 | ``` 230 | 231 | ## Examples 232 | 233 | - [Authenticate with Github (OAuth2/Phoenix)](https://github.com/scrogson/oauth2_example) 234 | 235 | ## License 236 | 237 | The MIT License (MIT) 238 | 239 | Copyright (c) 2015 Sonny Scroggin 240 | 241 | Permission is hereby granted, free of charge, to any person obtaining a copy 242 | of this software and associated documentation files (the "Software"), to deal 243 | in the Software without restriction, including without limitation the rights 244 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 245 | copies of the Software, and to permit persons to whom the Software is 246 | furnished to do so, subject to the following conditions: 247 | 248 | The above copyright notice and this permission notice shall be included in all 249 | copies or substantial portions of the Software. 250 | 251 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 252 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 253 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 254 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 255 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 256 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 257 | SOFTWARE. 258 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, level: :debug 4 | 5 | config :oauth2, 6 | # first commit sha of this library 7 | client_id: "0bee1126b1a1381d9cab60bcd52349484451808a", 8 | # second commit sha 9 | client_secret: "f715d64092fe81c396ac383e97f8a7eca40e7c89", 10 | redirect_uri: "http://example.com/auth/callback", 11 | request_opts: [], 12 | middleware: [] 13 | 14 | if Mix.env() == :test do 15 | config :oauth2, adapter: Tesla.Adapter.Hackney 16 | end 17 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/oauth2/strategy.ex", 4 | "test/support/test_helpers.ex" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /lib/oauth2.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2 do 2 | @moduledoc """ 3 | The OAuth2 specification 4 | 5 | [RFC6749](http://tools.ietf.org/html/rfc6749) 6 | 7 | The OAuth 2.0 authorization framework enables a third-party 8 | application to obtain limited access to an HTTP service, either on 9 | behalf of a resource owner by orchestrating an approval interaction 10 | between the resource owner and the HTTP service, or by allowing the 11 | third-party application to obtain access on its own behalf. 12 | 13 | ## API 14 | 15 | Currently implemented strategies: 16 | 17 | - Authorization Code 18 | - Password 19 | - Client Credentials 20 | 21 | ### Authorization Code Flow (AuthCode Strategy) 22 | 23 | Initialize a client with your `client_id`, `client_secret`, and `site`. 24 | 25 | client = OAuth2.Client.new([ 26 | strategy: OAuth2.Strategy.AuthCode, # default strategy is AuthCode 27 | client_id: "client_id", 28 | client_secret: "abc123", 29 | site: "https://auth.example.com", 30 | redirect_uri: "https://example.com/auth/callback" 31 | ]) 32 | 33 | Generate the authorization URL and redirect the user to the provider. 34 | 35 | OAuth2.Client.authorize_url(client) 36 | # => "https://auth.example.com/oauth/authorize?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback&response_type=code" 37 | 38 | Use the authorization code returned from the provider to obtain an access token. 39 | 40 | client = OAuth2.Client.get_token!(client, code: "someauthcode") 41 | 42 | Use the access token to make a request for resources 43 | 44 | resource = OAuth2.Client.get!(client, "/api/resource").body 45 | """ 46 | end 47 | -------------------------------------------------------------------------------- /lib/oauth2/access_token.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.AccessToken do 2 | @moduledoc """ 3 | This module defines the `OAuth2.AccessToken` struct and provides functionality 4 | to make authorized requests to an OAuth2 provider using the AccessToken 5 | returned by the provider. 6 | 7 | The `OAuth2.AccessToken` struct is created for you when you use the 8 | `OAuth2.Client.get_token` 9 | """ 10 | 11 | import OAuth2.Util 12 | 13 | alias OAuth2.AccessToken 14 | 15 | @standard ["access_token", "refresh_token", "expires_in", "token_type"] 16 | 17 | @type access_token :: binary 18 | @type refresh_token :: binary | nil 19 | @type expires_at :: integer 20 | @type token_type :: binary 21 | @type other_params :: %{binary => binary} 22 | @type body :: binary | map | list 23 | 24 | @type t :: %__MODULE__{ 25 | access_token: access_token, 26 | refresh_token: refresh_token, 27 | expires_at: expires_at, 28 | token_type: token_type, 29 | other_params: other_params 30 | } 31 | 32 | defstruct access_token: "", 33 | refresh_token: nil, 34 | expires_at: nil, 35 | token_type: "Bearer", 36 | other_params: %{} 37 | 38 | @doc """ 39 | Returns a new `OAuth2.AccessToken` struct given the access token `string` or a response `map`. 40 | 41 | Note if giving a map, please be sure to make the key a `string` no an `atom`. 42 | 43 | This is used by `OAuth2.Client.get_token/4` to create the `OAuth2.AccessToken` struct. 44 | 45 | ### Example 46 | 47 | iex> OAuth2.AccessToken.new("abc123") 48 | %OAuth2.AccessToken{access_token: "abc123", expires_at: nil, other_params: %{}, refresh_token: nil, token_type: "Bearer"} 49 | 50 | iex> OAuth2.AccessToken.new(%{"access_token" => "abc123"}) 51 | %OAuth2.AccessToken{access_token: "abc123", expires_at: nil, other_params: %{}, refresh_token: nil, token_type: "Bearer"} 52 | """ 53 | @spec new(binary) :: t 54 | def new(token) when is_binary(token) do 55 | new(%{"access_token" => token}) 56 | end 57 | 58 | @spec new(%{binary => binary}) :: t 59 | def new(response) when is_map(response) do 60 | {std, other} = Map.split(response, @standard) 61 | 62 | struct(AccessToken, 63 | access_token: std["access_token"], 64 | refresh_token: std["refresh_token"], 65 | expires_at: (std["expires_in"] || other["expires"]) |> expires_at, 66 | token_type: std["token_type"] |> normalize_token_type(), 67 | other_params: other 68 | ) 69 | end 70 | 71 | @doc """ 72 | Determines if the access token will expire or not. 73 | 74 | Returns `true` unless `expires_at` is `nil`. 75 | """ 76 | @spec expires?(AccessToken.t()) :: boolean 77 | def expires?(%AccessToken{expires_at: nil} = _token), do: false 78 | def expires?(_), do: true 79 | 80 | @doc """ 81 | Determines if the access token has expired. 82 | """ 83 | def expired?(token) do 84 | expires?(token) && unix_now() > token.expires_at 85 | end 86 | 87 | @doc """ 88 | Returns a unix timestamp based on now + expires_at (in seconds). 89 | """ 90 | def expires_at(nil), do: nil 91 | 92 | def expires_at(val) when is_binary(val) do 93 | val 94 | |> Integer.parse() 95 | |> elem(0) 96 | |> expires_at 97 | end 98 | 99 | def expires_at(int), do: unix_now() + int 100 | 101 | defp normalize_token_type(nil), do: "Bearer" 102 | defp normalize_token_type("bearer"), do: "Bearer" 103 | defp normalize_token_type(string), do: string 104 | end 105 | -------------------------------------------------------------------------------- /lib/oauth2/client.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Client do 2 | @moduledoc ~S""" 3 | This module defines the `OAuth2.Client` struct and is responsible for building 4 | and establishing a request for an access token. 5 | 6 | ### Notes 7 | 8 | * If a full url is given (e.g. "http://www.example.com/api/resource") then it 9 | will use that otherwise you can specify an endpoint (e.g. "/api/resource") and 10 | it will append it to the `Client.site`. 11 | 12 | * The headers from the `Client.headers` are appended to the request headers. 13 | 14 | ### Examples 15 | 16 | client = OAuth2.Client.new(token: "abc123") 17 | 18 | case OAuth2.Client.get(client, "/some/resource") do 19 | {:ok, %OAuth2.Response{body: body}} -> 20 | "Yay!!" 21 | {:error, %OAuth2.Response{body: body}} -> 22 | "Something bad happen: #{inspect body}" 23 | {:error, %OAuth2.Error{reason: reason}} -> 24 | reason 25 | end 26 | 27 | response = OAuth2.Client.get!(client, "/some/resource") 28 | 29 | response = OAuth2.Client.post!(client, "/some/other/resources", %{foo: "bar"}) 30 | """ 31 | 32 | alias OAuth2.{AccessToken, Client, Error, Request, Response} 33 | 34 | @type authorize_url :: binary 35 | @type body :: any 36 | @type client_id :: binary 37 | @type client_secret :: binary 38 | @type headers :: [{binary, binary}] 39 | @type param :: binary | %{binary => param} | [param] 40 | @type params :: %{binary => param} | Keyword.t() | %{} 41 | @type redirect_uri :: binary 42 | @type ref :: reference | nil 43 | @type request_opts :: Keyword.t() 44 | @type serializers :: %{binary => module} 45 | @type site :: binary 46 | @type strategy :: module 47 | @type token :: AccessToken.t() | nil 48 | @type token_method :: :post | :get | atom 49 | @type token_url :: binary 50 | 51 | @type t :: %Client{ 52 | authorize_url: authorize_url, 53 | client_id: client_id, 54 | client_secret: client_secret, 55 | headers: headers, 56 | params: params, 57 | redirect_uri: redirect_uri, 58 | ref: ref, 59 | request_opts: request_opts, 60 | serializers: serializers, 61 | site: site, 62 | strategy: strategy, 63 | token: token, 64 | token_method: token_method, 65 | token_url: token_url 66 | } 67 | 68 | defstruct authorize_url: "/oauth/authorize", 69 | client_id: "", 70 | client_secret: "", 71 | headers: [], 72 | params: %{}, 73 | redirect_uri: "", 74 | ref: nil, 75 | request_opts: [], 76 | serializers: %{}, 77 | site: "", 78 | strategy: OAuth2.Strategy.AuthCode, 79 | token: nil, 80 | token_method: :post, 81 | token_url: "/oauth/token" 82 | 83 | @doc """ 84 | Builds a new `OAuth2.Client` struct using the `opts` provided. 85 | 86 | ## Client struct fields 87 | 88 | * `authorize_url` - absolute or relative URL path to the authorization 89 | endpoint. Defaults to `"/oauth/authorize"` 90 | * `client_id` - the client_id for the OAuth2 provider 91 | * `client_secret` - the client_secret for the OAuth2 provider 92 | * `headers` - a list of request headers 93 | * `params` - a map of request parameters 94 | * `redirect_uri` - the URI the provider should redirect to after authorization 95 | or token requests 96 | * `request_opts` - a keyword list of request options that will be sent to the 97 | `hackney` client. See the [hackney documentation] for a list of available 98 | options. 99 | * `site` - the OAuth2 provider site host 100 | * `strategy` - a module that implements the appropriate OAuth2 strategy, 101 | default `OAuth2.Strategy.AuthCode` 102 | * `token` - `%OAuth2.AccessToken{}` struct holding the token for requests. 103 | * `token_method` - HTTP method to use to request token (`:get` or `:post`). 104 | Defaults to `:post` 105 | * `token_url` - absolute or relative URL path to the token endpoint. 106 | Defaults to `"/oauth/token"` 107 | 108 | ## Example 109 | 110 | iex> OAuth2.Client.new(token: "123") 111 | %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "", 112 | client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "", 113 | strategy: OAuth2.Strategy.AuthCode, 114 | token: %OAuth2.AccessToken{access_token: "123", expires_at: nil, 115 | other_params: %{}, refresh_token: nil, token_type: "Bearer"}, 116 | token_method: :post, token_url: "/oauth/token"} 117 | 118 | iex> token = OAuth2.AccessToken.new("123") 119 | iex> OAuth2.Client.new(token: token) 120 | %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "", 121 | client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "", 122 | strategy: OAuth2.Strategy.AuthCode, 123 | token: %OAuth2.AccessToken{access_token: "123", expires_at: nil, 124 | other_params: %{}, refresh_token: nil, token_type: "Bearer"}, 125 | token_method: :post, token_url: "/oauth/token"} 126 | 127 | [hackney documentation]: https://github.com/benoitc/hackney/blob/master/doc/hackney.md#request5 128 | """ 129 | @spec new(t, Keyword.t()) :: t 130 | def new(client \\ %Client{}, opts) do 131 | {token, opts} = Keyword.pop(opts, :token) 132 | {req_opts, opts} = Keyword.pop(opts, :request_opts, []) 133 | 134 | opts = 135 | opts 136 | |> Keyword.put(:token, process_token(token)) 137 | |> Keyword.put(:request_opts, Keyword.merge(client.request_opts, req_opts)) 138 | 139 | struct(client, opts) 140 | end 141 | 142 | defp process_token(nil), do: nil 143 | defp process_token(val) when is_binary(val), do: AccessToken.new(val) 144 | defp process_token(%AccessToken{} = token), do: token 145 | 146 | @doc """ 147 | Puts the specified `value` in the params for the given `key`. 148 | 149 | The key can be a `string` or an `atom`. Atoms are automatically 150 | convert to strings. 151 | """ 152 | @spec put_param(t, String.t() | atom, any) :: t 153 | def put_param(%Client{params: params} = client, key, value) do 154 | %{client | params: Map.put(params, "#{key}", value)} 155 | end 156 | 157 | @doc """ 158 | Set multiple params in the client in one call. 159 | """ 160 | @spec merge_params(t, params) :: t 161 | def merge_params(client, params) do 162 | params = 163 | Enum.reduce(params, %{}, fn {k, v}, acc -> 164 | Map.put(acc, "#{k}", v) 165 | end) 166 | 167 | %{client | params: Map.merge(client.params, params)} 168 | end 169 | 170 | @doc """ 171 | Adds a new header `key` if not present, otherwise replaces the 172 | previous value of that header with `value`. 173 | """ 174 | @spec put_header(t, binary, binary) :: t 175 | def put_header(%Client{headers: headers} = client, key, value) 176 | when is_binary(key) and is_binary(value) do 177 | key = String.downcase(key) 178 | %{client | headers: List.keystore(headers, key, 0, {key, value})} 179 | end 180 | 181 | @doc """ 182 | Set multiple headers in the client in one call. 183 | """ 184 | @spec put_headers(t, list) :: t 185 | def put_headers(%Client{} = client, []), do: client 186 | 187 | def put_headers(%Client{} = client, [{k, v} | rest]) do 188 | client 189 | |> put_header(k, v) 190 | |> put_headers(rest) 191 | end 192 | 193 | @doc false 194 | @spec authorize_url(t, list) :: {t, binary} 195 | def authorize_url(%Client{} = client, params \\ []) do 196 | client.strategy.authorize_url(client, params) |> to_url(:authorize_url) 197 | end 198 | 199 | @doc """ 200 | Returns the authorize url based on the client configuration. 201 | 202 | ## Example 203 | 204 | iex> OAuth2.Client.authorize_url!(%OAuth2.Client{}) 205 | "/oauth/authorize?client_id=&redirect_uri=&response_type=code" 206 | """ 207 | @spec authorize_url!(t, list) :: binary 208 | def authorize_url!(%Client{} = client, params \\ []) do 209 | {_, url} = authorize_url(client, params) 210 | url 211 | end 212 | 213 | @doc """ 214 | Register a serialization module for a given mime type. 215 | 216 | ## Example 217 | 218 | iex> client = OAuth2.Client.put_serializer(%OAuth2.Client{}, "application/json", Jason) 219 | %OAuth2.Client{serializers: %{"application/json" => Jason}} 220 | iex> OAuth2.Client.get_serializer(client, "application/json") 221 | Jason 222 | """ 223 | @spec put_serializer(t, binary, atom) :: t 224 | def put_serializer(%Client{serializers: serializers} = client, mime, module) 225 | when is_binary(mime) and is_atom(module) do 226 | %Client{client | serializers: Map.put(serializers, mime, module)} 227 | end 228 | 229 | @doc """ 230 | Un-register a serialization module for a given mime type. 231 | 232 | ## Example 233 | 234 | iex> client = OAuth2.Client.delete_serializer(%OAuth2.Client{}, "application/json") 235 | %OAuth2.Client{} 236 | iex> OAuth2.Client.get_serializer(client, "application/json") 237 | nil 238 | """ 239 | @spec delete_serializer(t, binary) :: t 240 | def delete_serializer(%Client{serializers: serializers} = client, mime) do 241 | %Client{client | serializers: Map.delete(serializers, mime)} 242 | end 243 | 244 | @doc false 245 | @spec get_serializer(t, binary) :: atom 246 | def get_serializer(%Client{serializers: serializers}, mime) do 247 | Map.get(serializers, mime) 248 | end 249 | 250 | @doc """ 251 | Fetches an `OAuth2.AccessToken` struct by making a request to the token endpoint. 252 | 253 | Returns the `OAuth2.Client` struct loaded with the access token which can then 254 | be used to make authenticated requests to an OAuth2 provider's API. 255 | 256 | ## Arguments 257 | 258 | * `client` - a `OAuth2.Client` struct with the strategy to use, defaults to 259 | `OAuth2.Strategy.AuthCode` 260 | * `params` - a keyword list of request parameters which will be encoded into 261 | a query string or request body depending on the selected strategy 262 | * `headers` - a list of request headers 263 | * `opts` - a Keyword list of request options which will be merged with 264 | `OAuth2.Client.request_opts` 265 | 266 | ## Options 267 | 268 | * `:recv_timeout` - the timeout (in milliseconds) of the request 269 | * `:proxy` - a proxy to be used for the request; it can be a regular url or a 270 | `{host, proxy}` tuple 271 | """ 272 | @spec get_token(t, params, headers, Keyword.t()) :: 273 | {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()} 274 | def get_token(%{token_method: method} = client, params \\ [], headers \\ [], opts \\ []) do 275 | {client, url} = token_url(client, params, headers) 276 | 277 | case Request.request(method, client, url, client.params, client.headers, opts) do 278 | {:ok, response} -> 279 | token = AccessToken.new(response.body) 280 | {:ok, %{client | headers: [], params: %{}, token: token}} 281 | 282 | {:error, error} -> 283 | {:error, error} 284 | end 285 | end 286 | 287 | @doc """ 288 | Same as `get_token/4` but raises `OAuth2.Error` if an error occurs during the 289 | request. 290 | """ 291 | @spec get_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t() 292 | def get_token!(client, params \\ [], headers \\ [], opts \\ []) do 293 | case get_token(client, params, headers, opts) do 294 | {:ok, client} -> 295 | client 296 | 297 | {:error, %Response{status_code: code, headers: headers, body: body}} -> 298 | raise %Error{ 299 | reason: """ 300 | Server responded with status: #{code} 301 | 302 | Headers: 303 | 304 | #{Enum.reduce(headers, "", fn {k, v}, acc -> acc <> "#{k}: #{v}\n" end)} 305 | Body: 306 | 307 | #{inspect(body)} 308 | """ 309 | } 310 | 311 | {:error, error} -> 312 | raise error 313 | end 314 | end 315 | 316 | @doc """ 317 | Refreshes an existing access token using a refresh token. 318 | """ 319 | @spec refresh_token(t, params, headers, Keyword.t()) :: 320 | {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()} 321 | def refresh_token(client, params \\ [], headers \\ [], opts \\ []) 322 | 323 | def refresh_token(%Client{token: %{refresh_token: nil}}, _params, _headers, _opts) do 324 | {:error, %Error{reason: "Refresh token not available."}} 325 | end 326 | 327 | def refresh_token( 328 | %Client{token: %{refresh_token: refresh_token}} = client, 329 | params, 330 | headers, 331 | opts 332 | ) do 333 | refresh_client = 334 | %{client | strategy: OAuth2.Strategy.Refresh, token: nil} 335 | |> Client.put_param(:refresh_token, refresh_token) 336 | 337 | case Client.get_token(refresh_client, params, headers, opts) do 338 | {:ok, %Client{} = client} -> 339 | if client.token.refresh_token do 340 | {:ok, client} 341 | else 342 | {:ok, put_in(client.token.refresh_token, refresh_token)} 343 | end 344 | 345 | {:error, error} -> 346 | {:error, error} 347 | end 348 | end 349 | 350 | @doc """ 351 | Calls `refresh_token/4` but raises `Error` if there an error occurs. 352 | """ 353 | @spec refresh_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t() 354 | def refresh_token!(%Client{} = client, params \\ [], headers \\ [], opts \\ []) do 355 | case refresh_token(client, params, headers, opts) do 356 | {:ok, %Client{} = client} -> client 357 | {:error, error} -> raise error 358 | end 359 | end 360 | 361 | @doc """ 362 | Adds `authorization` header for basic auth. 363 | """ 364 | @spec basic_auth(t) :: t 365 | def basic_auth(%OAuth2.Client{client_id: id, client_secret: secret} = client) do 366 | put_header(client, "authorization", "Basic " <> Base.encode64(id <> ":" <> secret)) 367 | end 368 | 369 | @doc """ 370 | Makes a `GET` request to the given `url` using the `OAuth2.AccessToken` 371 | struct. 372 | """ 373 | @spec get(t, binary, headers, Keyword.t()) :: 374 | {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()} 375 | def get(%Client{} = client, url, headers \\ [], opts \\ []), 376 | do: Request.request(:get, client, url, "", headers, opts) 377 | 378 | @doc """ 379 | Same as `get/4` but returns a `OAuth2.Response` or `OAuth2.Error` exception if 380 | the request results in an error. 381 | """ 382 | @spec get!(t, binary, headers, Keyword.t()) :: Response.t() | Error.t() 383 | def get!(%Client{} = client, url, headers \\ [], opts \\ []), 384 | do: Request.request!(:get, client, url, "", headers, opts) 385 | 386 | @doc """ 387 | Makes a `PUT` request to the given `url` using the `OAuth2.AccessToken` 388 | struct. 389 | """ 390 | @spec put(t, binary, body, headers, Keyword.t()) :: 391 | {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()} 392 | def put(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 393 | do: Request.request(:put, client, url, body, headers, opts) 394 | 395 | @doc """ 396 | Same as `put/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if 397 | the request results in an error. 398 | 399 | An `OAuth2.Error` exception is raised if the request results in an 400 | error tuple (`{:error, reason}`). 401 | """ 402 | @spec put!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t() 403 | def put!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 404 | do: Request.request!(:put, client, url, body, headers, opts) 405 | 406 | @doc """ 407 | Makes a `PATCH` request to the given `url` using the `OAuth2.AccessToken` 408 | struct. 409 | """ 410 | @spec patch(t, binary, body, headers, Keyword.t()) :: 411 | {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()} 412 | def patch(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 413 | do: Request.request(:patch, client, url, body, headers, opts) 414 | 415 | @doc """ 416 | Same as `patch/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if 417 | the request results in an error. 418 | 419 | An `OAuth2.Error` exception is raised if the request results in an 420 | error tuple (`{:error, reason}`). 421 | """ 422 | @spec patch!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t() 423 | def patch!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 424 | do: Request.request!(:patch, client, url, body, headers, opts) 425 | 426 | @doc """ 427 | Makes a `POST` request to the given URL using the `OAuth2.AccessToken`. 428 | """ 429 | @spec post(t, binary, body, headers, Keyword.t()) :: 430 | {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()} 431 | def post(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 432 | do: Request.request(:post, client, url, body, headers, opts) 433 | 434 | @doc """ 435 | Same as `post/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception 436 | if the request results in an error. 437 | 438 | An `OAuth2.Error` exception is raised if the request results in an 439 | error tuple (`{:error, reason}`). 440 | """ 441 | @spec post!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t() 442 | def post!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 443 | do: Request.request!(:post, client, url, body, headers, opts) 444 | 445 | @doc """ 446 | Makes a `DELETE` request to the given URL using the `OAuth2.AccessToken`. 447 | """ 448 | @spec delete(t, binary, body, headers, Keyword.t()) :: 449 | {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()} 450 | def delete(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 451 | do: Request.request(:delete, client, url, body, headers, opts) 452 | 453 | @doc """ 454 | Same as `delete/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception 455 | if the request results in an error. 456 | 457 | An `OAuth2.Error` exception is raised if the request results in an 458 | error tuple (`{:error, reason}`). 459 | """ 460 | @spec delete!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t() 461 | def delete!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []), 462 | do: Request.request!(:delete, client, url, body, headers, opts) 463 | 464 | defp to_url(%Client{token_method: :post} = client, :token_url) do 465 | {client, endpoint(client, client.token_url)} 466 | end 467 | 468 | defp to_url(client, endpoint) do 469 | endpoint = Map.get(client, endpoint) 470 | url = endpoint(client, endpoint) <> "?" <> URI.encode_query(client.params) 471 | {client, url} 472 | end 473 | 474 | defp token_url(client, params, headers) do 475 | client 476 | |> token_post_header() 477 | |> client.strategy.get_token(params, headers) 478 | |> to_url(:token_url) 479 | end 480 | 481 | defp token_post_header(%Client{token_method: :post} = client) do 482 | client 483 | |> put_header("content-type", "application/x-www-form-urlencoded") 484 | |> put_header("accept", "application/json") 485 | end 486 | 487 | defp token_post_header(%Client{} = client), do: client 488 | 489 | defp endpoint(client, <<"/"::utf8, _::binary>> = endpoint), 490 | do: client.site <> endpoint 491 | 492 | defp endpoint(_client, endpoint), do: endpoint 493 | end 494 | -------------------------------------------------------------------------------- /lib/oauth2/error.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Error do 2 | @type t :: %__MODULE__{ 3 | reason: binary 4 | } 5 | 6 | defexception [:reason] 7 | 8 | @doc false 9 | def message(%__MODULE__{reason: :econnrefused}), do: "Connection refused" 10 | def message(%__MODULE__{reason: reason}) when is_binary(reason), do: reason 11 | def message(%__MODULE__{reason: reason}), do: inspect(reason) 12 | end 13 | -------------------------------------------------------------------------------- /lib/oauth2/request.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Request do 2 | @moduledoc false 3 | 4 | require Logger 5 | import OAuth2.Util 6 | 7 | alias OAuth2.{Client, Error, Response} 8 | 9 | @type body :: any 10 | 11 | @doc """ 12 | Makes a request of given type to the given URL using the `OAuth2.AccessToken`. 13 | """ 14 | @spec request(atom, Client.t(), binary, body, Client.headers(), Keyword.t()) :: 15 | {:ok, Response.t()} | {:ok, reference} | {:error, Response.t()} | {:error, Error.t()} 16 | def request(method, %Client{} = client, url, body, headers, opts) do 17 | url = process_url(client, url) 18 | headers = req_headers(client, headers) |> normalize_headers() |> Enum.uniq() 19 | content_type = content_type(headers) 20 | serializer = Client.get_serializer(client, content_type) 21 | body = encode_request_body(body, content_type, serializer) 22 | headers = process_request_headers(headers, content_type) 23 | req_opts = Keyword.merge(client.request_opts, opts) 24 | params = opts[:params] || %{} 25 | 26 | if Application.get_env(:oauth2, :debug) do 27 | Logger.debug(""" 28 | OAuth2 Provider Request 29 | url: #{inspect(url)} 30 | method: #{inspect(method)} 31 | headers: #{inspect(headers)} 32 | body: #{inspect(body)} 33 | req_opts: #{inspect(req_opts)} 34 | """) 35 | end 36 | 37 | case Tesla.request(http_client(), 38 | method: method, 39 | url: url, 40 | query: params, 41 | headers: headers, 42 | body: body, 43 | opts: [adapter: req_opts] 44 | ) do 45 | {:ok, %{status: status, headers: headers, body: body}} when is_binary(body) -> 46 | process_body(client, status, headers, body) 47 | 48 | {:ok, %{body: ref}} when is_reference(ref) -> 49 | {:ok, ref} 50 | 51 | {:error, reason} -> 52 | {:error, %Error{reason: reason}} 53 | end 54 | end 55 | 56 | @doc """ 57 | Same as `request/6` but returns `OAuth2.Response` or raises an error if an 58 | error occurs during the request. 59 | 60 | An `OAuth2.Error` exception is raised if the request results in an 61 | error tuple (`{:error, reason}`). 62 | """ 63 | @spec request!(atom, Client.t(), binary, body, Client.headers(), Keyword.t()) :: Response.t() 64 | def request!(method, %Client{} = client, url, body, headers, opts) do 65 | case request(method, client, url, body, headers, opts) do 66 | {:ok, resp} -> 67 | resp 68 | 69 | {:error, %Response{status_code: code, headers: headers, body: body}} -> 70 | raise %Error{ 71 | reason: """ 72 | Server responded with status: #{code} 73 | 74 | Headers: 75 | 76 | #{Enum.reduce(headers, "", fn {k, v}, acc -> acc <> "#{k}: #{v}\n" end)} 77 | Body: 78 | 79 | #{inspect(body)} 80 | """ 81 | } 82 | 83 | {:error, error} -> 84 | raise error 85 | end 86 | end 87 | 88 | defp http_client do 89 | adapter = Application.get_env(:oauth2, :adapter, Tesla.Adapter.Httpc) 90 | 91 | middleware = Application.get_env(:oauth2, :middleware, []) 92 | 93 | Tesla.client(middleware, adapter) 94 | end 95 | 96 | defp process_url(client, url) do 97 | case String.downcase(url) do 98 | <<"http://"::utf8, _::binary>> -> url 99 | <<"https://"::utf8, _::binary>> -> url 100 | _ -> client.site <> url 101 | end 102 | end 103 | 104 | defp process_body(client, status, headers, body) when is_binary(body) do 105 | resp = Response.new(client, status, headers, body) 106 | 107 | case status do 108 | status when status in 200..399 -> 109 | {:ok, resp} 110 | 111 | status when status in 400..599 -> 112 | {:error, resp} 113 | end 114 | end 115 | 116 | defp req_headers(%Client{token: nil} = client, headers), 117 | do: headers ++ client.headers 118 | 119 | defp req_headers(%Client{token: token} = client, headers), 120 | do: [authorization_header(token) | headers] ++ client.headers 121 | 122 | defp authorization_header(token), 123 | do: {"authorization", "#{token.token_type} #{token.access_token}"} 124 | 125 | defp normalize_headers(headers), 126 | do: Enum.map(headers, fn {key, val} -> {to_string(key) |> String.downcase(), val} end) 127 | 128 | defp process_request_headers(headers, content_type) do 129 | case List.keyfind(headers, "accept", 0) do 130 | {"accept", _} -> 131 | headers 132 | 133 | nil -> 134 | [{"accept", content_type} | headers] 135 | end 136 | end 137 | 138 | defp encode_request_body("", _, _), do: "" 139 | defp encode_request_body([], _, _), do: "" 140 | 141 | defp encode_request_body(body, "application/x-www-form-urlencoded", _), 142 | do: URI.encode_query(body) 143 | 144 | defp encode_request_body(body, _mime, nil) do 145 | body 146 | end 147 | 148 | defp encode_request_body(body, _mime, serializer) do 149 | serializer.encode!(body) 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/oauth2/response.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Response do 2 | @moduledoc """ 3 | Defines the `OAuth2.Response` struct which is created from the HTTP responses 4 | made by the `OAuth2.Client` module. 5 | 6 | ## Struct fields 7 | 8 | * `status_code` - HTTP response status code 9 | * `headers` - HTTP response headers 10 | * `body` - Parsed HTTP response body (based on "content-type" header) 11 | """ 12 | 13 | require Logger 14 | import OAuth2.Util 15 | alias OAuth2.Client 16 | 17 | @type status_code :: integer 18 | @type headers :: [{binary, binary}] 19 | @type body :: binary | map | list 20 | 21 | @type t :: %__MODULE__{ 22 | status_code: status_code, 23 | headers: headers, 24 | body: body 25 | } 26 | 27 | defstruct status_code: nil, headers: [], body: nil 28 | 29 | @doc false 30 | @spec new(Client.t(), integer, headers, body) :: t 31 | def new(client, code, headers, body) do 32 | headers = process_headers(headers) 33 | content_type = content_type(headers) 34 | serializer = Client.get_serializer(client, content_type) 35 | body = decode_response_body(body, content_type, serializer) 36 | resp = %__MODULE__{status_code: code, headers: headers, body: body} 37 | 38 | if Application.get_env(:oauth2, :debug) do 39 | Logger.debug("OAuth2 Provider Response #{inspect(resp)}") 40 | end 41 | 42 | resp 43 | end 44 | 45 | defp process_headers(headers) do 46 | Enum.map(headers, fn {k, v} -> {String.downcase(k), v} end) 47 | end 48 | 49 | defp decode_response_body("", _type, _), do: "" 50 | defp decode_response_body(" ", _type, _), do: "" 51 | 52 | defp decode_response_body(body, _type, serializer) when serializer != nil do 53 | serializer.decode!(body) 54 | end 55 | 56 | # Facebook sends text/plain tokens!? 57 | defp decode_response_body(body, "text/plain", _) do 58 | case URI.decode_query(body) do 59 | %{"access_token" => _} = token -> token 60 | _ -> body 61 | end 62 | end 63 | 64 | defp decode_response_body(body, "application/x-www-form-urlencoded", _) do 65 | URI.decode_query(body) 66 | end 67 | 68 | defp decode_response_body(body, _mime, nil) do 69 | body 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/oauth2/serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Serializer do 2 | @moduledoc """ 3 | A serializer is responsible for encoding/decoding request/response bodies. 4 | 5 | ## Example 6 | 7 | defmodule MyApp.JSON do 8 | def encode!(data), do: Jason.encode!(data) 9 | def decode!(binary), do: Jason.decode!(binary) 10 | end 11 | """ 12 | 13 | @callback encode!(map) :: binary 14 | @callback decode!(binary) :: map 15 | end 16 | -------------------------------------------------------------------------------- /lib/oauth2/strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy do 2 | @moduledoc ~S""" 3 | The OAuth2 strategy specification. 4 | 5 | This module defines the required callbacks for all strategies. 6 | 7 | ## Examples 8 | 9 | Here's an example strategy for authenticating with GitHub. 10 | 11 | defmodule GitHub do 12 | use OAuth2.Strategy 13 | 14 | # Public API 15 | 16 | def new do 17 | OAuth2.Client.new([ 18 | strategy: __MODULE__, 19 | client_id: "abc123", 20 | client_secret: "abcdefg", 21 | redirect_uri: "http://myapp.com/auth/callback", 22 | site: "https://api.github.com", 23 | authorize_url: "https://github.com/login/oauth/authorize", 24 | token_url: "https://github.com/login/oauth/access_token" 25 | ]) 26 | end 27 | 28 | def authorize_url!(params \\ []) do 29 | new() 30 | |> put_param(:scope, "user,public_repo") 31 | |> OAuth2.Client.authorize_url!(params) 32 | end 33 | 34 | def get_token!(params \\ [], headers \\ []) do 35 | OAuth2.Client.get_token!(new(), params, headers) 36 | end 37 | 38 | # Strategy Callbacks 39 | 40 | def authorize_url(client, params) do 41 | OAuth2.Strategy.AuthCode.authorize_url(client, params) 42 | end 43 | 44 | def get_token(client, params, headers) do 45 | client 46 | |> put_header("Accept", "application/json") 47 | |> OAuth2.Strategy.AuthCode.get_token(params, headers) 48 | end 49 | end 50 | 51 | ## Usage 52 | 53 | Generate the authorize URL and redirect the client for authorization. 54 | 55 | GitHub.authorize_url! 56 | 57 | Capture the `code` in your callback route on your server and use it to obtain an access token. 58 | 59 | token = GitHub.get_token!(code: code) 60 | 61 | Use the access token to access desired resources. 62 | 63 | user = OAuth2.AccessToken.get!(token, "/user") 64 | """ 65 | 66 | alias OAuth2.Client 67 | 68 | @doc """ 69 | Builds the URL to the authorization endpoint. 70 | 71 | ## Example 72 | 73 | def authorize_url(client, params) do 74 | client 75 | |> put_param(:response_type, "code") 76 | |> put_param(:client_id, client.client_id) 77 | |> put_param(:redirect_uri, client.redirect_uri) 78 | |> merge_params(params) 79 | end 80 | """ 81 | @callback authorize_url(Client.t(), Client.params()) :: Client.t() 82 | 83 | @doc """ 84 | Builds the URL to the token endpoint. 85 | 86 | ## Example 87 | 88 | def get_token(client, params, headers) do 89 | client 90 | |> put_param(:code, params[:code]) 91 | |> put_param(:grant_type, "authorization_code") 92 | |> put_param(:client_id, client.client_id) 93 | |> put_param(:client_secret, client.client_secret) 94 | |> put_param(:redirect_uri, client.redirect_uri) 95 | |> merge_params(params) 96 | |> put_headers(headers) 97 | end 98 | """ 99 | @callback get_token(Client.t(), Client.params(), Client.headers()) :: Client.t() 100 | 101 | defmacro __using__(_) do 102 | quote do 103 | @behaviour OAuth2.Strategy 104 | import OAuth2.Client 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/oauth2/strategy/auth_code.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.AuthCode do 2 | @moduledoc """ 3 | The Authorization Code Strategy. 4 | 5 | http://tools.ietf.org/html/rfc6749#section-1.3.1 6 | 7 | The authorization code is obtained by using an authorization server 8 | as an intermediary between the client and resource owner. Instead of 9 | requesting authorization directly from the resource owner, the client 10 | directs the resource owner to an authorization server (via its 11 | user-agent as defined in [RFC2616]), which in turn directs the 12 | resource owner back to the client with the authorization code. 13 | 14 | Before directing the resource owner back to the client with the 15 | authorization code, the authorization server authenticates the 16 | resource owner and obtains authorization. Because the resource owner 17 | only authenticates with the authorization server, the resource 18 | owner's credentials are never shared with the client. 19 | 20 | The authorization code provides a few important security benefits, 21 | such as the ability to authenticate the client, as well as the 22 | transmission of the access token directly to the client without 23 | passing it through the resource owner's user-agent and potentially 24 | exposing it to others, including the resource owner. 25 | """ 26 | 27 | use OAuth2.Strategy 28 | 29 | @doc """ 30 | Configures the authorization URL endpoint of the provider with additional 31 | query parameters. 32 | """ 33 | @impl true 34 | def authorize_url(client, params) do 35 | client 36 | |> put_param(:response_type, "code") 37 | |> put_param(:client_id, client.client_id) 38 | |> put_param(:redirect_uri, client.redirect_uri) 39 | |> merge_params(params) 40 | end 41 | 42 | @doc """ 43 | Retrieve an access token given the specified validation code. 44 | """ 45 | @impl true 46 | def get_token(client, params, headers) do 47 | {code, params} = Keyword.pop(params, :code, client.params["code"]) 48 | 49 | unless code do 50 | raise OAuth2.Error, reason: "Missing required key `code` for `#{inspect(__MODULE__)}`" 51 | end 52 | 53 | client 54 | |> put_param(:code, code) 55 | |> put_param(:grant_type, "authorization_code") 56 | |> put_param(:client_id, client.client_id) 57 | |> put_param(:redirect_uri, client.redirect_uri) 58 | |> merge_params(params) 59 | |> basic_auth() 60 | |> put_headers(headers) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/oauth2/strategy/client_credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.ClientCredentials do 2 | @moduledoc """ 3 | The Client Credentials Strategy 4 | 5 | http://tools.ietf.org/html/rfc6749#section-1.3.4 6 | 7 | The client credentials (or other forms of client authentication) can 8 | be used as an authorization grant when the authorization scope is 9 | limited to the protected resources under the control of the client, 10 | or to protected resources previously arranged with the authorization 11 | server. Client credentials are used as an authorization grant 12 | typically when the client is acting on its own behalf (the client is 13 | also the resource owner) or is requesting access to protected 14 | resources based on an authorization previously arranged with the 15 | authorization server. 16 | """ 17 | 18 | use OAuth2.Strategy 19 | 20 | @doc """ 21 | Not used for this strategy. 22 | """ 23 | @impl true 24 | def authorize_url(_client, _params) do 25 | raise OAuth2.Error, reason: "This strategy does not implement `authorize_url`." 26 | end 27 | 28 | @doc """ 29 | Retrieve an access token given the specified strategy. 30 | """ 31 | @impl true 32 | def get_token(client, params, headers) do 33 | {auth_scheme, params} = Keyword.pop(params, :auth_scheme, "auth_header") 34 | 35 | client 36 | |> put_param(:grant_type, "client_credentials") 37 | |> auth_scheme(auth_scheme) 38 | |> merge_params(params) 39 | |> put_headers(headers) 40 | end 41 | 42 | defp auth_scheme(client, "auth_header"), do: basic_auth(client) 43 | defp auth_scheme(client, "request_body"), do: request_body(client) 44 | 45 | defp request_body(client) do 46 | client 47 | |> put_param(:client_id, client.client_id) 48 | |> put_param(:client_secret, client.client_secret) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/oauth2/strategy/password.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.Password do 2 | @moduledoc """ 3 | The Resource Owner Password Credentials Authorization Strategy. 4 | 5 | http://tools.ietf.org/html/rfc6749#section-1.3.3 6 | 7 | The resource owner password credentials (i.e., username and password) 8 | can be used directly as an authorization grant to obtain an access 9 | token. The credentials should only be used when there is a high 10 | degree of trust between the resource owner and the client (e.g., the 11 | client is part of the device operating system or a highly privileged 12 | application), and when other authorization grant types are not 13 | available (such as an authorization code). 14 | 15 | Even though this grant type requires direct client access to the 16 | resource owner credentials, the resource owner credentials are used 17 | for a single request and are exchanged for an access token. This 18 | grant type can eliminate the need for the client to store the 19 | resource owner credentials for future use, by exchanging the 20 | credentials with a long-lived access token or refresh token. 21 | """ 22 | 23 | use OAuth2.Strategy 24 | 25 | @doc """ 26 | Not used for this strategy. 27 | """ 28 | @impl true 29 | def authorize_url(_client, _params) do 30 | raise OAuth2.Error, reason: "This strategy does not implement `authorize_url`." 31 | end 32 | 33 | @doc """ 34 | Retrieve an access token given the specified End User username and password. 35 | """ 36 | @impl true 37 | def get_token(client, params, headers) do 38 | {username, params} = Keyword.pop(params, :username, client.params["username"]) 39 | {password, params} = Keyword.pop(params, :password, client.params["password"]) 40 | 41 | unless username && password do 42 | raise OAuth2.Error, 43 | reason: "Missing required keys `username` and `password` for #{inspect(__MODULE__)}" 44 | end 45 | 46 | client 47 | |> put_param(:username, username) 48 | |> put_param(:password, password) 49 | |> put_param(:grant_type, "password") 50 | |> merge_params(params) 51 | |> basic_auth() 52 | |> put_headers(headers) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/oauth2/strategy/refresh.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.Refresh do 2 | @moduledoc """ 3 | The Refresh Token Strategy. 4 | 5 | https://tools.ietf.org/html/rfc6749#section-1.5 6 | 7 | Refresh tokens are credentials used to obtain access tokens. Refresh 8 | tokens are issued to the client by the authorization server and are 9 | used to obtain a new access token when the current access token 10 | becomes invalid or expires, or to obtain additional access tokens 11 | with identical or narrower scope (access tokens may have a shorter 12 | lifetime and fewer permissions than authorized by the resource 13 | owner). Issuing a refresh token is optional at the discretion of the 14 | authorization server. If the authorization server issues a refresh 15 | token, it is included when issuing an access token. 16 | 17 | A refresh token is a string representing the authorization granted to 18 | the client by the resource owner. The string is usually opaque to 19 | the client. The token denotes an identifier used to retrieve the 20 | authorization information. Unlike access tokens, refresh tokens are 21 | intended for use only with authorization servers and are never sent 22 | to resource servers. 23 | """ 24 | 25 | use OAuth2.Strategy 26 | 27 | @doc """ 28 | Not used for this strategy. 29 | """ 30 | @impl true 31 | def authorize_url(_client, _params) do 32 | raise OAuth2.Error, reason: "This strategy does not implement `authorize_url`." 33 | end 34 | 35 | @doc """ 36 | Refresh an access token given the specified validation code. 37 | """ 38 | @impl true 39 | def get_token(client, params, headers) do 40 | {token, params} = Keyword.pop(params, :refresh_token, client.params["refresh_token"]) 41 | 42 | unless token do 43 | raise OAuth2.Error, 44 | reason: "Missing required key `refresh_token` for `#{inspect(__MODULE__)}`" 45 | end 46 | 47 | client 48 | |> put_param(:refresh_token, token) 49 | |> put_param(:grant_type, "refresh_token") 50 | |> merge_params(params) 51 | |> basic_auth() 52 | |> put_headers(headers) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/oauth2/util.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Util do 2 | @moduledoc false 3 | 4 | @spec unix_now :: integer 5 | def unix_now do 6 | {mega, sec, _micro} = :os.timestamp() 7 | mega * 1_000_000 + sec 8 | end 9 | 10 | @spec content_type([{binary, binary}]) :: binary 11 | def content_type(headers) do 12 | case get_content_type(headers) do 13 | {_, content_type} -> 14 | content_type 15 | |> remove_params 16 | |> parse_content_type 17 | 18 | nil -> 19 | "application/json" 20 | end 21 | end 22 | 23 | defp remove_params(binary) do 24 | [content_type | _] = String.split(binary, ";") 25 | content_type 26 | end 27 | 28 | defp parse_content_type(content_type) do 29 | case String.split(content_type, "/") do 30 | [type, subtype] -> 31 | type <> "/" <> subtype 32 | 33 | _ -> 34 | raise OAuth2.Error, reason: "bad content-type: #{content_type}" 35 | end 36 | end 37 | 38 | defp get_content_type(headers) do 39 | List.keyfind(headers, "content-type", 0) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/scrogson/oauth2" 5 | @version "2.1.0" 6 | 7 | def project do 8 | [ 9 | app: :oauth2, 10 | name: "OAuth2", 11 | version: @version, 12 | elixir: "~> 1.2", 13 | deps: deps(), 14 | package: package(), 15 | description: description(), 16 | docs: docs(), 17 | elixirc_paths: elixirc_paths(Mix.env()), 18 | test_coverage: [tool: ExCoveralls], 19 | preferred_cli_env: [ 20 | coveralls: :test, 21 | "coveralls.detail": :test, 22 | "coveralls.html": :test, 23 | docs: :dev 24 | ], 25 | dialyzer: dialyzer() 26 | ] 27 | end 28 | 29 | def application do 30 | [extra_applications: [:logger]] 31 | end 32 | 33 | defp dialyzer do 34 | [ 35 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:tesla, "~> 1.5"}, 42 | 43 | # Test dependencies 44 | {:hackney, "~> 1.17", only: [:dev, :test]}, 45 | {:jason, "~> 1.0", only: [:dev, :test]}, 46 | {:bypass, "~> 0.9", only: :test}, 47 | {:plug_cowboy, "~> 1.0", only: :test}, 48 | 49 | # Tools 50 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 51 | {:dialyxir, ">= 0.0.0", only: [:dev], runtime: false}, 52 | {:ex_doc, ">= 0.0.0", only: [:dev], runtime: false}, 53 | {:excoveralls, ">= 0.0.0", only: [:test], runtime: false} 54 | ] 55 | end 56 | 57 | defp description do 58 | "An Elixir OAuth 2.0 Client Library" 59 | end 60 | 61 | defp docs do 62 | [ 63 | extras: ["CHANGELOG.md", "README.md": [title: "Overview"]], 64 | main: "readme", 65 | source_ref: "v#{@version}", 66 | source_url: @source_url, 67 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 68 | formatters: ["html"] 69 | ] 70 | end 71 | 72 | defp package do 73 | [ 74 | files: ["lib", "mix.exs", "CHANGELOG.md", "README.md", "LICENSE"], 75 | maintainers: ["Sonny Scroggin"], 76 | licenses: ["MIT"], 77 | links: %{ 78 | Changelog: "https://hexdocs.pm/oauth2/changelog.html", 79 | GitHub: @source_url 80 | } 81 | ] 82 | end 83 | 84 | defp elixirc_paths(:test), do: ["lib", "test/support"] 85 | defp elixirc_paths(_), do: ["lib"] 86 | end 87 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "bypass": {:hex, :bypass, "0.9.0", "4cedcd326eeec497e0090a73d351cbd0f11e39329ddf9095931b03da9b6dc417", [:mix], [{:cowboy, "~> 1.0 or ~> 2.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ede64318ac7bff9126d83a962a1605f4fd407fa0d1a6c844b3b012773d6beadd"}, 4 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 5 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "f4763bbe08233eceed6f24bc4fcc8d71c17cfeafa6439157c57349aa1bb4f17c"}, 6 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm", "db622da03aa039e6366ab953e31186cc8190d32905e33788a1acb22744e6abd2"}, 7 | "credo": {:hex, :credo, "1.6.1", "7dc76dcdb764a4316c1596804c48eada9fff44bd4b733a91ccbf0c0f368be61e", [: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", "698607fb5993720c7e93d2d8e76f2175bba024de964e160e2f7151ef3ab82ac5"}, 8 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 9 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, 10 | "earmark_parser": {:hex, :earmark_parser, "1.4.18", "e1b2be73eb08a49fb032a0208bf647380682374a725dfb5b9e510def8397f6f2", [:mix], [], "hexpm", "114a0e85ec3cf9e04b811009e73c206394ffecfcc313e0b346de0d557774ee97"}, 11 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 12 | "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, 13 | "excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"}, 14 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 15 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.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", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 18 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 19 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 23 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 24 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 25 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 26 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 27 | "plug": {:hex, :plug, "1.8.2", "0bcce1daa420f189a6491f3940cc77ea7fb1919761175c9c3b59800d897440fc", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "54c8bbd5062cb32880aa1afc9894a823b1c18a47a1821a552887310b561cd418"}, 28 | "plug_cowboy": {:hex, :plug_cowboy, "1.0.0", "2e2a7d3409746d335f451218b8bb0858301c3de6d668c3052716c909936eb57a", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "01d201427a8a1f4483be2465a98b45f5e82263327507fe93404a61c51eb9e9a8"}, 29 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, 30 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm", "6e56493a862433fccc3aca3025c946d6720d8eedf6e3e6fb911952a7071c357f"}, 31 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 32 | "tesla": {:hex, :tesla, "1.5.0", "7ee3616be87024a2b7231ae14474310c9b999c3abb1f4f8dbc70f86bd9678eef", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "1d0385e41fbd76af3961809088aef15dec4c2fdaab97b1c93c6484cb3695a122"}, 33 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 34 | } 35 | -------------------------------------------------------------------------------- /priv/plts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ueberauth/oauth2/e8bb2105a6dedaaf423efc1d02b4666cbc8ca43d/priv/plts/.gitkeep -------------------------------------------------------------------------------- /test/oauth2/access_token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.AccessTokenTest do 2 | use ExUnit.Case, async: true 3 | doctest OAuth2.AccessToken 4 | 5 | import OAuth2.TestHelpers, only: [unix_now: 0] 6 | 7 | alias OAuth2.{AccessToken, Client, Response} 8 | 9 | test "new from binary token" do 10 | token = AccessToken.new("abc123") 11 | assert token.access_token == "abc123" 12 | end 13 | 14 | test "new with 'expires_in' param" do 15 | response = 16 | Response.new( 17 | %Client{}, 18 | 200, 19 | [{"content-type", "application/x-www-form-urlencoded"}], 20 | "access_token=abc123&expires_in=123" 21 | ) 22 | 23 | token = AccessToken.new(response.body) 24 | assert token.access_token == "abc123" 25 | assert token.expires_at == 123 + unix_now() 26 | assert token.token_type == "Bearer" 27 | assert token.other_params == %{} 28 | end 29 | 30 | test "new with 'expires' param" do 31 | response = 32 | Response.new( 33 | %Client{}, 34 | 200, 35 | [{"content-type", "application/x-www-form-urlencoded"}], 36 | "access_token=abc123&expires=123" 37 | ) 38 | 39 | token = AccessToken.new(response.body) 40 | assert token.access_token == "abc123" 41 | assert token.expires_at == 123 + unix_now() 42 | assert token.token_type == "Bearer" 43 | assert token.other_params == %{"expires" => "123"} 44 | end 45 | 46 | test "new from text/plain content-type" do 47 | response = 48 | Response.new( 49 | %Client{}, 50 | 200, 51 | [{"content-type", "text/plain"}], 52 | "access_token=abc123&expires=123" 53 | ) 54 | 55 | token = AccessToken.new(response.body) 56 | assert token.access_token == "abc123" 57 | assert token.expires_at == 123 + unix_now() 58 | assert token.token_type == "Bearer" 59 | assert token.other_params == %{"expires" => "123"} 60 | end 61 | 62 | test "expires?" do 63 | assert AccessToken.expires?(%AccessToken{expires_at: 0}) 64 | refute AccessToken.expires?(%AccessToken{expires_at: nil}) 65 | end 66 | 67 | test "expired?" do 68 | assert AccessToken.expired?(%AccessToken{expires_at: 0}) 69 | refute AccessToken.expired?(%AccessToken{expires_at: nil}) 70 | end 71 | 72 | test "expires_in" do 73 | assert AccessToken.expires_at(nil) == nil 74 | assert AccessToken.expires_at(3600) == unix_now() + 3600 75 | assert AccessToken.expires_at("3600") == unix_now() + 3600 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/oauth2/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.ClientTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | doctest OAuth2.Client 5 | 6 | import OAuth2.Client 7 | import OAuth2.TestHelpers 8 | 9 | alias OAuth2.Client 10 | alias OAuth2.Response 11 | 12 | setup do 13 | server = Bypass.open() 14 | client = build_client(site: bypass_server(server)) 15 | client_with_token = tokenize_client(client) 16 | async_client = async_client(client) 17 | basic_auth = Base.encode64(client.client_id <> ":" <> client.client_secret) 18 | 19 | {:ok, 20 | basic_auth: basic_auth, 21 | client: client, 22 | server: server, 23 | client_with_token: client_with_token, 24 | async_client: async_client} 25 | end 26 | 27 | test "authorize_url!", %{client: client, server: server} do 28 | uri = URI.parse(authorize_url!(client)) 29 | assert "#{uri.scheme}://#{uri.host}:#{uri.port}" == client.site 30 | assert uri.port == server.port 31 | assert uri.path == "/oauth/authorize" 32 | 33 | query = URI.decode_query(uri.query) 34 | assert query["client_id"] == client.client_id 35 | assert query["redirect_uri"] == client.redirect_uri 36 | assert query["response_type"] == "code" 37 | end 38 | 39 | test "get_token, get_token!", %{client: client, server: server} do 40 | bypass(server, "POST", "/oauth/token", fn conn -> 41 | assert conn.query_string == "" 42 | assert get_req_header(conn, "accept") == ["application/json"] 43 | 44 | send_resp(conn, 200, ~s({"access_token":"test1234"})) 45 | end) 46 | 47 | assert {:ok, client} = Client.get_token(client, code: "code1234") 48 | assert client.token.access_token == "test1234" 49 | 50 | assert %Client{} = Client.get_token!(client, code: "code1234") 51 | end 52 | 53 | test "get_token, get_token! when `:token_method` is `:get`", %{client: client, server: server} do 54 | client = %{client | token_method: :get} 55 | 56 | bypass(server, "GET", "/oauth/token", fn conn -> 57 | refute conn.query_string == "" 58 | assert get_req_header(conn, "accept") == ["application/json"] 59 | assert conn.query_params["code"] == "code1234" 60 | assert conn.query_params["redirect_uri"] 61 | send_resp(conn, 200, ~s({"access_token":"test1234","token_type":"bearer"})) 62 | end) 63 | 64 | assert {:ok, %Client{token: token}} = Client.get_token(client, code: "code1234") 65 | assert token.access_token == "test1234" 66 | assert %Client{token: token} = Client.get_token!(client, code: "code1234") 67 | assert token.access_token == "test1234" 68 | end 69 | 70 | test "get_token, get_token! when response error", %{client: client, server: server} do 71 | code = [code: "code1234"] 72 | 73 | bypass(server, "POST", "/oauth/token", fn conn -> 74 | assert conn.query_string == "" 75 | assert get_req_header(conn, "accept") == ["application/json"] 76 | send_resp(conn, 500, ~s({"error":"missing_client_id"})) 77 | end) 78 | 79 | assert {:error, error} = Client.get_token(client, code) 80 | assert %Response{body: body, status_code: 500} = error 81 | assert body == %{"error" => "missing_client_id"} 82 | 83 | assert_raise OAuth2.Error, ~r/Body/, fn -> 84 | Client.get_token!(client, code) 85 | end 86 | end 87 | 88 | test "refresh_token and refresh_token! with a POST", %{ 89 | basic_auth: base64, 90 | server: server, 91 | client_with_token: client 92 | } do 93 | bypass(server, "POST", "/oauth/token", fn conn -> 94 | assert get_req_header(conn, "authorization") == ["Basic #{base64}"] 95 | assert get_req_header(conn, "accept") == ["application/json"] 96 | assert get_req_header(conn, "content-type") == ["application/x-www-form-urlencoded"] 97 | 98 | conn 99 | |> put_resp_header("content-type", "application/json") 100 | |> send_resp( 101 | 200, 102 | ~s({"access_token":"new-access-token","refresh_token":"new-refresh-token"}) 103 | ) 104 | end) 105 | 106 | {:error, error} = Client.refresh_token(client) 107 | assert error.reason =~ ~r/token not available/ 108 | 109 | assert_raise OAuth2.Error, ~r/token not available/, fn -> 110 | Client.refresh_token!(client) 111 | end 112 | 113 | token = client.token 114 | client = %{client | token: %{token | refresh_token: "abcdefg"}} 115 | assert {:ok, client_a} = Client.refresh_token(client, []) 116 | assert client_a.token.access_token == "new-access-token" 117 | assert client_a.token.refresh_token == "new-refresh-token" 118 | 119 | assert client_b = Client.refresh_token!(client, []) 120 | assert client_b.token.access_token == "new-access-token" 121 | assert client_b.token.refresh_token == "new-refresh-token" 122 | end 123 | 124 | test "refresh token when response missing refresh_token", %{ 125 | basic_auth: base64, 126 | server: server, 127 | client_with_token: client 128 | } do 129 | bypass(server, "POST", "/oauth/token", fn conn -> 130 | assert get_req_header(conn, "authorization") == ["Basic #{base64}"] 131 | assert get_req_header(conn, "accept") == ["application/json"] 132 | assert get_req_header(conn, "content-type") == ["application/x-www-form-urlencoded"] 133 | 134 | conn 135 | |> put_resp_header("content-type", "application/json") 136 | |> send_resp(200, ~s({"access_token":"new-access-token"})) 137 | end) 138 | 139 | token = client.token 140 | client = %{client | token: %{token | refresh_token: "old-refresh-token"}} 141 | assert {:ok, client} = Client.refresh_token(client, []) 142 | assert client.token.access_token == "new-access-token" 143 | assert client.token.refresh_token == "old-refresh-token" 144 | end 145 | 146 | test "put_param, merge_params", %{client: client} do 147 | assert map_size(client.params) == 0 148 | 149 | client = put_param(client, :scope, "user,email") 150 | assert client.params["scope"] == "user,email" 151 | 152 | client = merge_params(client, scope: "overridden") 153 | assert client.params["scope"] == "overridden" 154 | 155 | client = put_param(client, "scope", "binary keys work too") 156 | assert client.params["scope"] == "binary keys work too" 157 | end 158 | 159 | test "put_header, put_headers", %{client: client} do 160 | client = put_header(client, "accepts", "application/json") 161 | assert {"accepts", "application/json"} = List.keyfind(client.headers, "accepts", 0) 162 | 163 | client = 164 | put_headers(client, [{"accepts", "application/xml"}, {"content-type", "application/xml"}]) 165 | 166 | assert {"accepts", "application/xml"} = List.keyfind(client.headers, "accepts", 0) 167 | assert {"content-type", "application/xml"} = List.keyfind(client.headers, "content-type", 0) 168 | end 169 | 170 | test "basic_auth", %{client: client} do 171 | %OAuth2.Client{client_id: id, client_secret: secret} = client 172 | client = basic_auth(client) 173 | 174 | assert {"authorization", value} = List.keyfind(client.headers, "authorization", 0) 175 | assert value == "Basic " <> Base.encode64(id <> ":" <> secret) 176 | end 177 | 178 | ## GET 179 | 180 | test "GET", %{server: server, client_with_token: client} do 181 | bypass(server, "GET", "/api/user/1", [token: client.token], fn conn -> 182 | json(conn, 200, %{id: 1}) 183 | end) 184 | 185 | {:ok, result} = Client.get(client, "/api/user/1") 186 | assert result.status_code == 200 187 | assert result.body["id"] == 1 188 | 189 | result = Client.get!(client, "/api/user/1") 190 | assert result.status_code == 200 191 | assert result.body["id"] == 1 192 | end 193 | 194 | test "GET with async options", %{server: server, async_client: client} do 195 | body = :binary.copy("a", 8000) 196 | 197 | bypass(server, "GET", "/api/user/1", [token: client.token], fn conn -> 198 | send_resp(conn, 200, body) 199 | end) 200 | 201 | {:ok, ref} = Client.get(client, "/api/user/1") 202 | 203 | resp_body = stream(ref) 204 | assert resp_body == body 205 | end 206 | 207 | test "GET with with_body: true", %{server: server, client_with_token: client} do 208 | bypass(server, "GET", "/api/user/1", [token: client.token], fn conn -> 209 | json(conn, 200, %{id: 1}) 210 | end) 211 | 212 | {:ok, result} = Client.get(client, "/api/user/1", [], with_body: true) 213 | assert result.status_code == 200 214 | assert result.body["id"] == 1 215 | 216 | result = Client.get!(client, "/api/user/1") 217 | assert result.status_code == 200 218 | assert result.body["id"] == 1 219 | end 220 | 221 | defp stream(ref, buffer \\ []) do 222 | receive do 223 | {:hackney_response, ^ref, :done} -> 224 | IO.iodata_to_binary(buffer) 225 | 226 | {:hackney_response, ^ref, binary} -> 227 | stream(ref, buffer ++ [binary]) 228 | end 229 | end 230 | 231 | ## POST 232 | 233 | test "POST", %{server: server, client_with_token: client} do 234 | title = "Totally awesome blog post" 235 | 236 | bypass(server, "POST", "/api/posts", [token: client.token], fn conn -> 237 | json(conn, 200, %{id: 1, title: title}) 238 | end) 239 | 240 | {:ok, result} = Client.post(client, "/api/posts", %{title: title}) 241 | assert result.status_code == 200 242 | assert result.body["id"] == 1 243 | assert result.body["title"] == title 244 | 245 | result = Client.post!(client, "/api/posts", %{title: title}) 246 | assert result.status_code == 200 247 | assert result.body["id"] == 1 248 | assert result.body["title"] == title 249 | end 250 | 251 | ## PUT 252 | 253 | test "PUT", %{server: server, client_with_token: client} do 254 | title = "Totally awesome blog post!" 255 | 256 | bypass(server, "PUT", "/api/posts/1", [token: client.token], fn conn -> 257 | json(conn, 200, %{id: 1, title: title}) 258 | end) 259 | 260 | {:ok, result} = Client.put(client, "/api/posts/1", %{id: 1, title: title}) 261 | assert result.status_code == 200 262 | assert result.body["id"] == 1 263 | assert result.body["title"] == title 264 | 265 | result = Client.put!(client, "/api/posts/1", %{id: 1, title: title}) 266 | assert result.status_code == 200 267 | assert result.body["id"] == 1 268 | assert result.body["title"] == title 269 | end 270 | 271 | ## PATCH 272 | 273 | test "PATCH", %{server: server, client_with_token: client} do 274 | title = "Totally awesome blog post!" 275 | 276 | bypass(server, "PATCH", "/api/posts/1", [token: client.token], fn conn -> 277 | json(conn, 200, %{id: 1, title: title}) 278 | end) 279 | 280 | {:ok, result} = Client.patch(client, "/api/posts/1", %{id: 1, title: title}) 281 | assert result.status_code == 200 282 | assert result.body["id"] == 1 283 | assert result.body["title"] == title 284 | 285 | result = Client.patch!(client, "/api/posts/1", %{id: 1, title: title}) 286 | assert result.status_code == 200 287 | assert result.body["id"] == 1 288 | assert result.body["title"] == title 289 | end 290 | 291 | ## DELETE 292 | 293 | test "DELETE", %{server: server, client_with_token: client} do 294 | bypass(server, "DELETE", "/api/posts/1", [token: client.token], fn conn -> 295 | json(conn, 204, "") 296 | end) 297 | 298 | {:ok, result} = Client.delete(client, "/api/posts/1") 299 | assert result.status_code == 204 300 | assert result.body == "" 301 | 302 | result = Client.delete!(client, "/api/posts/1") 303 | assert result.status_code == 204 304 | assert result.body == "" 305 | end 306 | 307 | test "params in opts turn into a query string", %{server: server, client_with_token: client} do 308 | Bypass.expect(server, fn conn -> 309 | assert conn.query_string == "access_token=#{client.token.access_token}" 310 | send_resp(conn, 200, "") 311 | end) 312 | 313 | assert {:ok, _} = 314 | Client.get(client, "/me", [], params: [access_token: client.token.access_token]) 315 | end 316 | 317 | test "follow redirects", %{server: server, client_with_token: client} do 318 | Bypass.expect(server, fn conn -> 319 | case conn.path_info do 320 | ["old"] -> 321 | conn 322 | |> put_resp_header("location", "http://localhost:#{server.port}/new") 323 | |> send_resp(302, "") 324 | 325 | ["new"] -> 326 | conn 327 | |> put_resp_content_type("text/html") 328 | |> send_resp(200, "ok") 329 | end 330 | end) 331 | 332 | assert {:ok, %{body: "ok", status_code: 200}} = 333 | Client.get(client, "/old", [], 334 | params: [access_token: client.token.access_token], 335 | follow_redirect: true 336 | ) 337 | end 338 | 339 | test "get returning 401 with no content", %{server: server, client_with_token: client} do 340 | bypass(server, "GET", "/api/user", [token: client.token], fn conn -> 341 | conn 342 | |> put_resp_header("content-type", "text/html") 343 | |> send_resp(401, " ") 344 | end) 345 | 346 | {:error, result} = Client.get(client, "/api/user") 347 | assert result.status_code == 401 348 | assert result.body == "" 349 | end 350 | 351 | test "bang functions raise errors", %{server: server, client: client} do 352 | Bypass.expect(server, fn conn -> 353 | json(conn, 400, %{error: "error"}) 354 | end) 355 | 356 | assert_raise OAuth2.Error, ~r/Server responded with status: 400/, fn -> 357 | Client.get!(client, "/api/error") 358 | end 359 | end 360 | 361 | test "connection error", %{server: server, client_with_token: client} do 362 | Bypass.down(server) 363 | 364 | assert_raise OAuth2.Error, "Connection refused", fn -> 365 | Client.get!(client, "/api/error") 366 | end 367 | 368 | Bypass.up(server) 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /test/oauth2/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.ErrorTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias OAuth2.Error 5 | 6 | test "message" do 7 | assert Error.message(%Error{reason: :econnrefused}) == "Connection refused" 8 | assert Error.message(%Error{reason: "blah"}) == "blah" 9 | assert Error.message(%Error{reason: :blah}) == ":blah" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/oauth2/response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.ResponseTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias OAuth2.Response 5 | 6 | import ExUnit.CaptureLog 7 | 8 | test "debug response body" do 9 | Application.put_env(:oauth2, :debug, true) 10 | 11 | output = 12 | capture_log(fn -> 13 | Response.new(%OAuth2.Client{}, 200, [{"content-type", "text/plain"}], "hello") 14 | end) 15 | 16 | assert output =~ ~s(OAuth2 Provider Response) 17 | assert output =~ ~s(body: "hello") 18 | 19 | Application.put_env(:oauth2, :debug, false) 20 | end 21 | 22 | test "text/plain body passes through body" do 23 | response = Response.new(%OAuth2.Client{}, 200, [{"content-type", "text/plain"}], "hello") 24 | assert response.body == "hello" 25 | end 26 | 27 | test "always parse body by serializer if it exists" do 28 | client = OAuth2.Client.put_serializer(%OAuth2.Client{}, "text/plain", Jason) 29 | response = Response.new(client, 200, [{"content-type", "text/plain"}], ~S({"hello": "world"})) 30 | assert response.body == %{"hello" => "world"} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/oauth2/strategy/auth_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.AuthCodeTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | import OAuth2.TestHelpers 6 | 7 | alias OAuth2.Client 8 | alias OAuth2.Strategy.AuthCode 9 | 10 | setup do 11 | server = Bypass.open() 12 | client = build_client(strategy: AuthCode, site: bypass_server(server)) 13 | {:ok, client: client, server: server} 14 | end 15 | 16 | test "authorize_url", %{client: client, server: server} do 17 | client = AuthCode.authorize_url(client, []) 18 | assert "http://localhost:#{server.port}" == client.site 19 | 20 | assert client.params["client_id"] == client.client_id 21 | assert client.params["redirect_uri"] == client.redirect_uri 22 | assert client.params["response_type"] == "code" 23 | end 24 | 25 | test "get_token", %{client: client, server: server} do 26 | code = "abc1234" 27 | access_token = "access-token-1234" 28 | base64 = Base.encode64(client.client_id <> ":" <> client.client_secret) 29 | 30 | Bypass.expect(server, fn conn -> 31 | assert conn.method == "POST" 32 | assert conn.request_path == "/oauth/token" 33 | assert get_req_header(conn, "content-type") == ["application/x-www-form-urlencoded"] 34 | assert get_req_header(conn, "authorization") == ["Basic #{base64}"] 35 | 36 | {:ok, body, conn} = read_body(conn) 37 | body = URI.decode_query(body) 38 | 39 | assert body["grant_type"] == "authorization_code" 40 | assert body["code"] == code 41 | assert body["client_id"] == client.client_id 42 | assert body["redirect_uri"] == client.redirect_uri 43 | 44 | send_resp(conn, 200, ~s({"access_token":"#{access_token}"})) 45 | end) 46 | 47 | assert {:ok, %Client{token: token}} = Client.get_token(client, code: code) 48 | assert token.access_token == access_token 49 | end 50 | 51 | test "get_token throws and error if there is no 'code' param" do 52 | assert_raise OAuth2.Error, ~r/Missing required key/, fn -> 53 | AuthCode.get_token(build_client(), [], []) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/oauth2/strategy/client_credentials_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.ClientCredentialsTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias OAuth2.Client 6 | alias OAuth2.Strategy.ClientCredentials 7 | import OAuth2.TestHelpers 8 | 9 | setup do 10 | server = Bypass.open() 11 | client = build_client(strategy: ClientCredentials, site: bypass_server(server)) 12 | {:ok, client: client, server: server} 13 | end 14 | 15 | test "authorize_url", %{client: client} do 16 | assert_raise OAuth2.Error, ~r/This strategy does not implement/, fn -> 17 | Client.authorize_url(client) 18 | end 19 | end 20 | 21 | test "get_token: auth_scheme defaults to 'auth_header'", %{client: client} do 22 | client = ClientCredentials.get_token(client, [], []) 23 | base64 = Base.encode64(client.client_id <> ":" <> client.client_secret) 24 | assert client.headers == [{"authorization", "Basic #{base64}"}] 25 | assert client.params["grant_type"] == "client_credentials" 26 | end 27 | 28 | test "get_token: Duplicated auth_header ", %{client: client, server: server} do 29 | Bypass.expect(server, fn conn -> 30 | base64 = Base.encode64(client.client_id <> ":" <> client.client_secret) 31 | assert get_req_header(conn, "authorization") == ["Basic #{base64}"] 32 | 33 | send_resp( 34 | conn, 35 | 200, 36 | ~s({"access_token": "123456==", "token_type": "bearer", "expires_in": "999" }) 37 | ) 38 | end) 39 | 40 | client = Client.get_token!(client) 41 | 42 | assert client.token.access_token == "123456==" 43 | assert List.keyfind(client.headers, "authorization", 0) == nil 44 | end 45 | 46 | test "get_token: with auth_scheme set to 'request_body'", %{client: client} do 47 | client = ClientCredentials.get_token(client, [auth_scheme: "request_body"], []) 48 | assert client.headers == [] 49 | assert client.params["grant_type"] == "client_credentials" 50 | assert client.params["client_id"] == client.client_id 51 | assert client.params["client_secret"] == client.client_secret 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/oauth2/strategy/password_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.PasswordTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias OAuth2.Strategy.Password 5 | import OAuth2.Client 6 | import OAuth2.TestHelpers 7 | 8 | setup do 9 | client = build_client(strategy: Password, site: "http://example.com") 10 | {:ok, client: client} 11 | end 12 | 13 | test "authorize_url", %{client: client} do 14 | assert_raise OAuth2.Error, ~r/This strategy does not implement/, fn -> 15 | authorize_url(client) 16 | end 17 | end 18 | 19 | test "get_token when username and password given in params", %{client: client} do 20 | client = Password.get_token(client, [username: "scrogson", password: "password"], []) 21 | base64 = Base.encode64(client.client_id <> ":" <> client.client_secret) 22 | 23 | assert client.params["username"] == "scrogson" 24 | assert client.params["password"] == "password" 25 | assert client.params["grant_type"] == "password" 26 | 27 | assert List.keyfind(client.headers, "authorization", 0) == 28 | {"authorization", "Basic #{base64}"} 29 | end 30 | 31 | test "get_token when username and password updated via put_param", %{client: client} do 32 | client = 33 | client 34 | |> put_param(:username, "scrogson") 35 | |> put_param(:password, "password") 36 | |> Password.get_token([], []) 37 | 38 | assert client.params["username"] == "scrogson" 39 | assert client.params["password"] == "password" 40 | end 41 | 42 | test "get_token when username and password are not provided", %{client: client} do 43 | assert_raise OAuth2.Error, ~r/Missing required/, fn -> 44 | Password.get_token(client, [], []) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/oauth2/strategy/refresh_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.Strategy.RefreshTest do 2 | use ExUnit.Case, async: true 3 | 4 | import OAuth2.TestHelpers 5 | 6 | alias OAuth2.Strategy.Refresh 7 | 8 | test "authorize_url" do 9 | assert_raise OAuth2.Error, ~r/This strategy does not implement/, fn -> 10 | Refresh.authorize_url(build_client(), []) 11 | end 12 | end 13 | 14 | test "get_token" do 15 | client = build_client() 16 | client = Refresh.get_token(client, [refresh_token: "refresh-token"], []) 17 | base64 = Base.encode64(client.client_id <> ":" <> client.client_secret) 18 | 19 | assert client.params["grant_type"] == "refresh_token" 20 | assert client.params["refresh_token"] == "refresh-token" 21 | 22 | assert List.keyfind(client.headers, "authorization", 0) == 23 | {"authorization", "Basic #{base64}"} 24 | end 25 | 26 | test "get_token throws and error if there is no 'refresh_token' param" do 27 | assert_raise OAuth2.Error, ~r/Missing required key `refresh_token`/, fn -> 28 | Refresh.get_token(build_client(), [], []) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/oauth2/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.UtilTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias OAuth2.Util 5 | 6 | test "parses mime types" do 7 | assert "application/json" == Util.content_type([]) 8 | 9 | assert "application/vnd.api+json" == 10 | Util.content_type([{"content-type", "application/vnd.api+json"}]) 11 | 12 | assert "application/xml" == 13 | Util.content_type([{"content-type", "application/xml; version=1.0"}]) 14 | 15 | assert "application/json" == 16 | Util.content_type([{"content-type", "application/json;param;param"}]) 17 | 18 | assert_raise OAuth2.Error, fn -> 19 | Util.content_type([{"content-type", "trash; trash"}]) 20 | end 21 | 22 | assert_raise OAuth2.Error, fn -> 23 | Util.content_type([{"content-type", "trash"}]) 24 | end 25 | 26 | assert_raise OAuth2.Error, fn -> 27 | Util.content_type([{"content-type", "trash/trash/trash"}]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/oauth2_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OAuth2Test do 2 | use ExUnit.Case 3 | import OAuth2.TestHelpers 4 | 5 | doctest OAuth2 6 | 7 | @client build_client( 8 | client_id: "abc123", 9 | client_secret: "xyz987", 10 | site: "https://api.github.com", 11 | redirect_uri: "http://localhost/auth/callback" 12 | ) 13 | 14 | test "`new` delegates to `OAuth2.Client.new/1`" do 15 | client = @client 16 | assert client.strategy == OAuth2.Strategy.AuthCode 17 | assert client.site == "https://api.github.com" 18 | assert client.client_id == "abc123" 19 | assert client.client_secret == "xyz987" 20 | assert client.site == "https://api.github.com" 21 | assert client.authorize_url == "/oauth/authorize" 22 | assert client.token_url == "/oauth/token" 23 | assert client.token_method == :post 24 | assert client.params == %{} 25 | assert client.headers == [] 26 | assert client.redirect_uri == "http://localhost/auth/callback" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuth2.TestHelpers do 2 | @moduledoc false 3 | import Plug.Conn 4 | import ExUnit.Assertions 5 | 6 | def bypass_server(%Bypass{port: port}) do 7 | "http://localhost:#{port}" 8 | end 9 | 10 | def bypass(server, method, path, fun) do 11 | bypass(server, method, path, [], fun) 12 | end 13 | 14 | def bypass(server, method, path, opts, fun) do 15 | {token, opts} = Keyword.pop(opts, :token, nil) 16 | {accept, _opts} = Keyword.pop(opts, :accept, "json") 17 | 18 | Bypass.expect(server, fn conn -> 19 | conn = parse_req_body(conn) 20 | 21 | assert conn.method == method 22 | assert conn.request_path == path 23 | assert_accepts(conn, accept) 24 | assert_token(conn, token) 25 | 26 | fun.(conn) 27 | end) 28 | end 29 | 30 | def unix_now do 31 | {mega, sec, _micro} = :os.timestamp() 32 | mega * 1_000_000 + sec 33 | end 34 | 35 | defp parse_req_body(conn) do 36 | opts = [parsers: [:urlencoded, :json], pass: ["*/*"], json_decoder: Jason] 37 | Plug.Parsers.call(conn, Plug.Parsers.init(opts)) 38 | end 39 | 40 | defp assert_accepts(conn, accept) do 41 | mime = 42 | case accept do 43 | "json" -> "application/json" 44 | _ -> accept 45 | end 46 | 47 | assert get_req_header(conn, "accept") == [mime] 48 | end 49 | 50 | defp assert_token(_conn, nil), do: :ok 51 | 52 | defp assert_token(conn, token) do 53 | assert get_req_header(conn, "authorization") == ["Bearer #{token.access_token}"] 54 | end 55 | 56 | def json(conn, status, body \\ []) do 57 | conn 58 | |> put_resp_header("content-type", "application/json") 59 | |> send_resp(status, Jason.encode!(body)) 60 | end 61 | 62 | def build_client(opts \\ []) do 63 | default_client_opts() 64 | |> Keyword.merge(opts) 65 | |> OAuth2.Client.new() 66 | |> OAuth2.Client.put_serializer("application/json", Jason) 67 | end 68 | 69 | def tokenize_client(opts \\ [], %OAuth2.Client{} = client) do 70 | token = 71 | default_token_opts() 72 | |> Keyword.merge(opts) 73 | |> stringify_keys() 74 | |> OAuth2.AccessToken.new() 75 | 76 | %{client | token: token} 77 | end 78 | 79 | def async_client(%{request_opts: req_opts} = client) do 80 | async_opts = [async: true, stream_to: self()] 81 | %{client | request_opts: Keyword.merge(req_opts, async_opts)} 82 | end 83 | 84 | defp get_config(key) do 85 | Application.get_env(:oauth2, key) 86 | end 87 | 88 | defp default_client_opts do 89 | [ 90 | client_id: get_config(:client_id), 91 | client_secret: get_config(:client_secret), 92 | redirect_uri: get_config(:redirect_uri), 93 | request_opts: get_config(:request_opts) 94 | ] 95 | end 96 | 97 | defp default_token_opts do 98 | [access_token: "abcdefgh", expires_at: OAuth2.Util.unix_now() + 600, token_type: "Bearer"] 99 | end 100 | 101 | defp stringify_keys(dict) do 102 | dict 103 | |> Enum.map(fn {k, v} -> {Atom.to_string(k), v} end) 104 | |> Enum.into(%{}) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:bypass) 2 | Application.ensure_all_started(:hackney) 3 | Application.put_env(:oauth2, :warn_missing_serializer, false) 4 | ExUnit.start() 5 | --------------------------------------------------------------------------------