├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs └── test.exs ├── lib ├── ex_oauth2_provider.ex ├── ex_oauth2_provider │ ├── access_grants │ │ ├── access_grant.ex │ │ └── access_grants.ex │ ├── access_tokens │ │ ├── access_token.ex │ │ └── access_tokens.ex │ ├── applications │ │ ├── application.ex │ │ └── applications.ex │ ├── config.ex │ ├── keys.ex │ ├── mixin │ │ ├── expirable.ex │ │ ├── revocable.ex │ │ └── scopes.ex │ ├── oauth2 │ │ ├── authorization.ex │ │ ├── authorization │ │ │ ├── strategy │ │ │ │ └── code.ex │ │ │ ├── utils.ex │ │ │ └── utils │ │ │ │ └── response.ex │ │ ├── token.ex │ │ ├── token │ │ │ ├── strategy │ │ │ │ ├── authorization_code.ex │ │ │ │ ├── client_credentials.ex │ │ │ │ ├── password.ex │ │ │ │ ├── refresh_token.ex │ │ │ │ └── revoke.ex │ │ │ ├── utils.ex │ │ │ └── utils │ │ │ │ └── response.ex │ │ └── utils │ │ │ └── error.ex │ ├── plug.ex │ ├── plug │ │ ├── ensure_authenticated.ex │ │ ├── ensure_scopes.ex │ │ ├── error_handler.ex │ │ └── verify_header.ex │ ├── redirect_uri.ex │ ├── schema.ex │ ├── scopes.ex │ └── utils.ex └── mix │ ├── ex_oauth2_provider.ex │ ├── ex_oauth2_provider │ ├── config.ex │ ├── migration.ex │ └── schema.ex │ └── tasks │ ├── ex_oauth2_provider.ex │ ├── ex_oauth2_provider.gen.migration.ex │ ├── ex_oauth2_provider.gen.schemas.ex │ └── ex_oauth2_provider.install.ex ├── mix.exs ├── mix.lock └── test ├── ex_oauth2_provider ├── access_grants │ └── access_grants_test.exs ├── access_tokens │ └── access_tokens_test.exs ├── applications │ ├── application_test.exs │ └── applications_test.exs ├── config_test.exs ├── keys_test.exs ├── oauth2 │ ├── authorization │ │ └── strategy │ │ │ └── code_test.exs │ ├── authorization_test.exs │ ├── token │ │ └── strategy │ │ │ ├── authorization_code_test.exs │ │ │ ├── client_credentials_test.exs │ │ │ ├── password_test.exs │ │ │ ├── refresh_token_test.exs │ │ │ └── revoke_test.exs │ └── token_test.exs ├── plug │ ├── ensure_authenticated_test.exs │ ├── ensure_scopes_test.exs │ ├── error_handler_test.exs │ └── verify_header_test.exs ├── plug_test.exs ├── redirect_uri_test.exs ├── scopes_test.exs └── utils_test.exs ├── ex_oauth2_provider_test.exs ├── mix └── tasks │ ├── ex_oauth2_provider.gen.migration_test.exs │ ├── ex_oauth2_provider.gen.schemas_test.exs │ ├── ex_oauth2_provider.install_test.exs │ └── ex_oauth2_provider_test.exs ├── support ├── auth.ex ├── conn_case.ex ├── fixtures.ex ├── lib │ └── dummy │ │ ├── oauth_access_grants │ │ └── oauth_access_grant.ex │ │ ├── oauth_access_tokens │ │ └── oauth_access_token.ex │ │ ├── oauth_applications │ │ └── oauth_application.ex │ │ ├── repo.ex │ │ └── user.ex ├── mix │ └── test_case.ex ├── priv │ └── migrations │ │ ├── 1_create_user.exs │ │ └── 2_create_oauth_tables.exs ├── query_helpers.ex └── test_case.ex └── test_helper.exs /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - published 9 | 10 | jobs: 11 | test: 12 | # This ensures we run the test for only the PR or the push 13 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 14 | services: 15 | postgres: 16 | image: postgres:latest 17 | env: 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: postgres 20 | POSTGRES_DB: ex_oauth2_provider_test 21 | ports: 22 | - 5432:5432 23 | options: >- 24 | --health-cmd pg_isready 25 | --health-interval 10s 26 | --health-timeout 5s 27 | --health-retries 5 28 | strategy: 29 | matrix: 30 | include: 31 | - otp: 26.0 32 | elixir: 1.15.0 33 | os: ubuntu-latest 34 | - otp: 22.0 35 | elixir: 1.12.0 36 | # It's necessary to run on ubunto 20.04 for OTP 20 - 25 37 | # See https://github.com/erlef/setup-beam 38 | os: ubuntu-20.04 39 | runs-on: ${{ matrix.os }} 40 | name: OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }} 41 | steps: 42 | - uses: actions/checkout@v3 43 | - uses: erlef/setup-beam@v1 44 | with: 45 | otp-version: ${{ matrix.otp }} 46 | elixir-version: ${{ matrix.elixir }} 47 | - run: mix deps.get 48 | - run: mix test 49 | env: 50 | POSTGRES_URL: ecto://postgres:postgres@localhost/ex_oauth2_provider_test 51 | - run: MIX_ENV=test mix credo --ignore design.tagtodo 52 | deploy: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | if: github.event_name == 'release' && github.event.action == 'published' 56 | name: Deploy published release 57 | env: 58 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: erlef/setup-beam@v1 62 | with: 63 | otp-version: 26.0 64 | elixir-version: 1.15.0 65 | - run: mix deps.get 66 | - run: mix hex.publish --yes 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | /tmp 9 | .DS_Store 10 | /.elixir_ls 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.5.7 (2023-08-05) 4 | 5 | Requires Elixir 1.12+. 6 | 7 | * Permit native application redirect uri 8 | * Separate Ecto migration and field options to resolve ecto 3.8 deprecation 9 | 10 | ## v0.5.6 (2020-01-07) 11 | 12 | * Permit associations to be overridden 13 | * Updated the documentation for how to set application resource owner 14 | 15 | ## v0.5.5 (2019-10-31) 16 | 17 | * Fixed bug where `Mix.env` is called on runtime rather than compile time 18 | 19 | ## v0.5.4 (2019-08-05) 20 | 21 | * Improved error message for missing repo configuration 22 | * A server issue at hex.pm caused v0.5.3 to not be released correctly. Use v0.5.4 instead. 23 | 24 | ## v0.5.3 (2019-08-02) 25 | 26 | * Fixed bug in `ExOauth2Provider.RedirectURI.valid_for_authorization?/3` where the `:redirect_uri_match_fun` configuration option was not used 27 | * Deprecated `ExOauth2Provider.RedirectURI.matches?/2` 28 | 29 | ## v0.5.2 (2019-06-10) 30 | 31 | * Added `:redirect_uri_match_fun` configuration option for custom matching of redirect uri 32 | 33 | ## v0.5.1 (2019-05-08) 34 | 35 | * Relaxed plug requirement up to 2.0.0 36 | * Fix bug where otp app name could not be fetched in release 37 | 38 | ## v0.5.0 (2019-05-08) 39 | 40 | This is a full rewrite of the library, and are several breaking changes. You're encouraged to test your app well if you upgrade from 0.4. 41 | 42 | ### Upgrading from 0.4 43 | 44 | #### 1. Schema modules 45 | 46 | Schema modules are now generated when installing ExOauth2Provider. To upgrade please run `mix ex_oauth2_provider.install --no-migrations` to generate the schema files. 47 | 48 | In the `MyApp.OauthAccessGrants.OauthAccessGrant` schema module you should update the `timestamp/0` macro to ignore `:updated_at`: 49 | 50 | ```elixir 51 | schema "oauth_access_grants" do 52 | access_grant_fields() 53 | 54 | timestamps(updated_at: false) 55 | end 56 | ``` 57 | 58 | #### 2. Configuration 59 | 60 | Config now has the form `config :my_app, ExOauth2Provider`. You can still use the previous `config :ex_oauth2_provider, ExOauth2Provider` configuration, but you are encouraged to switch over to the app specific configuration. 61 | 62 | #### 3. Resource owner UUID configuration 63 | 64 | If your configuration has `:resource_owner` setting with a UUID, you should remove it and only use the module name for your user schema. UUID is now handled in the schema modules directly. 65 | 66 | The schemas can be generated with `mix ex_oauth2_provider.install --no-migrations --binary-id`. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2017-2019 Dan Schultzer & the Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env == :test do 4 | import_config "test.exs" 5 | end 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_oauth2_provider, namespace: Dummy 4 | config :ex_oauth2_provider, ExOauth2Provider, 5 | repo: Dummy.Repo, 6 | resource_owner: Dummy.Users.User, 7 | default_scopes: ~w(public), 8 | optional_scopes: ~w(read write), 9 | password_auth: {Dummy.Auth, :auth}, 10 | use_refresh_token: true, 11 | revoke_refresh_token_on_use: true, 12 | grant_flows: ~w(authorization_code client_credentials) 13 | 14 | config :ex_oauth2_provider, Dummy.Repo, 15 | database: "ex_oauth2_provider_test", 16 | pool: Ecto.Adapters.SQL.Sandbox, 17 | priv: "test/support/priv", 18 | url: System.get_env("POSTGRES_URL") 19 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider do 2 | @moduledoc """ 3 | A module that provides OAuth 2 capabilities for Elixir applications. 4 | 5 | ## Configuration 6 | config :my_app, ExOauth2Provider, 7 | repo: App.Repo, 8 | resource_owner: App.Users.User, 9 | default_scopes: ~w(public), 10 | optional_scopes: ~w(write update), 11 | native_redirect_uri: "urn:ietf:wg:oauth:2.0:oob", 12 | authorization_code_expires_in: 600, 13 | access_token_expires_in: 7200, 14 | use_refresh_token: false, 15 | revoke_refresh_token_on_use: false, 16 | force_ssl_in_redirect_uri: true, 17 | grant_flows: ~w(authorization_code client_credentials), 18 | password_auth: nil, 19 | access_token_response_body_handler: nil 20 | 21 | If `revoke_refresh_token_on_use` is set to true, 22 | refresh tokens will be revoked after a related access token is used. 23 | 24 | If `revoke_refresh_token_on_use` is not set to true, 25 | previous tokens are revoked as soon as a new access token is created. 26 | 27 | If `use_refresh_token` is set to true, the refresh_token grant flow 28 | is automatically enabled. 29 | 30 | If `password_auth` is set to a {module, method} tuple, the password 31 | grant flow is automatically enabled. 32 | 33 | If access_token_expires_in is set to nil, access tokens will never 34 | expire. 35 | """ 36 | 37 | alias ExOauth2Provider.{Config, AccessTokens} 38 | 39 | @doc """ 40 | Authenticate an access token. 41 | 42 | ## Example 43 | 44 | ExOauth2Provider.authenticate_token("Jf5rM8hQBc", otp_app: :my_app) 45 | 46 | ## Response 47 | 48 | {:ok, access_token} 49 | {:error, reason} 50 | """ 51 | @spec authenticate_token(binary(), keyword()) :: {:ok, map()} | {:error, any()} 52 | def authenticate_token(token, config \\ []) 53 | def authenticate_token(nil, _config), do: {:error, :token_inaccessible} 54 | def authenticate_token(token, config) do 55 | token 56 | |> load_access_token(config) 57 | |> maybe_revoke_previous_refresh_token(config) 58 | |> validate_access_token() 59 | |> load_resource_owner(config) 60 | end 61 | 62 | defp load_access_token(token, config) do 63 | case AccessTokens.get_by_token(token, config) do 64 | nil -> {:error, :token_not_found} 65 | access_token -> {:ok, access_token} 66 | end 67 | end 68 | 69 | defp maybe_revoke_previous_refresh_token({:error, error}, _config), do: {:error, error} 70 | defp maybe_revoke_previous_refresh_token({:ok, access_token}, config) do 71 | case Config.refresh_token_revoked_on_use?(config) do 72 | true -> revoke_previous_refresh_token(access_token, config) 73 | false -> {:ok, access_token} 74 | end 75 | end 76 | 77 | defp revoke_previous_refresh_token(access_token, config) do 78 | case AccessTokens.revoke_previous_refresh_token(access_token, config) do 79 | {:error, _any} -> {:error, :no_association_found} 80 | {:ok, _access_token} -> {:ok, access_token} 81 | end 82 | end 83 | 84 | defp validate_access_token({:error, error}), do: {:error, error} 85 | defp validate_access_token({:ok, access_token}) do 86 | case AccessTokens.is_accessible?(access_token) do 87 | true -> {:ok, access_token} 88 | false -> {:error, :token_inaccessible} 89 | end 90 | end 91 | 92 | defp load_resource_owner({:error, error}, _config), do: {:error, error} 93 | defp load_resource_owner({:ok, access_token}, config) do 94 | repo = Config.repo(config) 95 | access_token = repo.preload(access_token, :resource_owner) 96 | 97 | {:ok, access_token} 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/access_grants/access_grant.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.AccessGrants.AccessGrant do 2 | @moduledoc """ 3 | Handles the Ecto schema for access grant. 4 | 5 | ## Usage 6 | 7 | Configure `lib/my_project/oauth_access_grants/oauth_access_grant.ex` the following way: 8 | 9 | defmodule MyApp.OauthAccessGrants.OauthAccessGrant do 10 | use Ecto.Schema 11 | use ExOauth2Provider.AccessGrants.AccessGrant 12 | 13 | schema "oauth_access_grants" do 14 | access_grant_fields() 15 | 16 | timestamps() 17 | end 18 | end 19 | """ 20 | 21 | @type t :: Ecto.Schema.t() 22 | 23 | @doc false 24 | def attrs() do 25 | [ 26 | {:token, :string, [], null: false}, 27 | {:expires_in, :integer, [], null: false}, 28 | {:redirect_uri, :string, [], null: false}, 29 | {:revoked_at, :utc_datetime}, 30 | {:scopes, :string} 31 | ] 32 | end 33 | 34 | @doc false 35 | def assocs() do 36 | [ 37 | {:belongs_to, :resource_owner, :users}, 38 | {:belongs_to, :application, :applications} 39 | ] 40 | end 41 | 42 | @doc false 43 | def indexes() do 44 | [ 45 | {:token, true}, 46 | ] 47 | end 48 | 49 | defmacro __using__(config) do 50 | quote do 51 | use ExOauth2Provider.Schema, unquote(config) 52 | 53 | import unquote(__MODULE__), only: [access_grant_fields: 0] 54 | end 55 | end 56 | 57 | defmacro access_grant_fields do 58 | quote do 59 | ExOauth2Provider.Schema.fields(unquote(__MODULE__)) 60 | end 61 | end 62 | 63 | alias Ecto.Changeset 64 | alias ExOauth2Provider.{Mixin.Scopes, Utils} 65 | 66 | @spec changeset(Ecto.Schema.t(), map(), keyword()) :: Changeset.t() 67 | def changeset(grant, params, config) do 68 | grant 69 | |> Changeset.cast(params, [:redirect_uri, :expires_in, :scopes]) 70 | |> Changeset.assoc_constraint(:application) 71 | |> Changeset.assoc_constraint(:resource_owner) 72 | |> put_token() 73 | |> Scopes.put_scopes(grant.application.scopes, config) 74 | |> Scopes.validate_scopes(grant.application.scopes, config) 75 | |> Changeset.validate_required([:redirect_uri, :expires_in, :token, :resource_owner, :application]) 76 | |> Changeset.unique_constraint(:token) 77 | end 78 | 79 | @spec put_token(Ecto.Changeset.t()) :: Ecto.Changeset.t() 80 | def put_token(changeset) do 81 | Changeset.put_change(changeset, :token, Utils.generate_token()) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/access_grants/access_grants.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.AccessGrants do 2 | @moduledoc """ 3 | The boundary for the OauthAccessGrants system. 4 | """ 5 | 6 | alias ExOauth2Provider.Mixin.{Expirable, Revocable} 7 | alias ExOauth2Provider.{Applications.Application, AccessGrants.AccessGrant, Config} 8 | 9 | defdelegate revoke!(data, config \\ []), to: Revocable 10 | defdelegate revoke(data, config \\ []), to: Revocable 11 | 12 | @doc """ 13 | Gets a single access grant registered with an application. 14 | 15 | ## Examples 16 | 17 | iex> get_active_grant_for(application, "jE9dk", otp_app: :my_app) 18 | %OauthAccessGrant{} 19 | 20 | iex> get_active_grant_for(application, "jE9dk", otp_app: :my_app) 21 | ** nil 22 | 23 | """ 24 | @spec get_active_grant_for(Application.t(), binary(), keyword()) :: AccessGrant.t() | nil 25 | def get_active_grant_for(application, token, config \\ []) do 26 | config 27 | |> Config.access_grant() 28 | |> Config.repo(config).get_by(application_id: application.id, token: token) 29 | |> Expirable.filter_expired() 30 | |> Revocable.filter_revoked() 31 | end 32 | 33 | @doc """ 34 | Creates an access grant. 35 | 36 | ## Examples 37 | 38 | iex> create_grant(resource_owner, application, attrs) 39 | {:ok, %OauthAccessGrant{}} 40 | 41 | iex> create_grant(resource_owner, application, attrs) 42 | {:error, %Ecto.Changeset{}} 43 | 44 | """ 45 | @spec create_grant(Ecto.Schema.t(), Application.t(), map(), keyword()) :: {:ok, AccessGrant.t()} | {:error, term()} 46 | def create_grant(resource_owner, application, attrs, config \\ []) do 47 | config 48 | |> Config.access_grant() 49 | |> struct(resource_owner: resource_owner, application: application) 50 | |> AccessGrant.changeset(attrs, config) 51 | |> Config.repo(config).insert() 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/access_tokens/access_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.AccessTokens.AccessToken do 2 | @moduledoc """ 3 | Handles the Ecto schema for access token. 4 | 5 | ## Usage 6 | 7 | Configure `lib/my_project/oauth_access_tokens/oauth_access_token.ex` the following way: 8 | 9 | defmodule MyApp.OauthAccessTokens.OauthAccessToken do 10 | use Ecto.Schema 11 | use ExOauth2Provider.AccessTokens.AccessToken 12 | 13 | schema "oauth_access_tokens" do 14 | access_token_fields() 15 | 16 | timestamps() 17 | end 18 | end 19 | """ 20 | alias ExOauth2Provider.Schema 21 | 22 | @type t :: Ecto.Schema.t() 23 | 24 | @doc false 25 | def attrs() do 26 | [ 27 | {:token, :string, [], null: false}, 28 | {:refresh_token, :string}, 29 | {:expires_in, :integer}, 30 | {:revoked_at, :utc_datetime}, 31 | {:scopes, :string}, 32 | {:previous_refresh_token, :string, [default: ""], null: false} 33 | ] 34 | end 35 | 36 | @doc false 37 | def assocs() do 38 | [ 39 | {:belongs_to, :resource_owner, :users}, 40 | {:belongs_to, :application, :applications} 41 | ] 42 | end 43 | 44 | @doc false 45 | def indexes() do 46 | [ 47 | {:token, true}, 48 | {:refresh_token, true} 49 | ] 50 | end 51 | 52 | defmacro __using__(config) do 53 | quote do 54 | use ExOauth2Provider.Schema, unquote(config) 55 | 56 | import unquote(__MODULE__), only: [access_token_fields: 0] 57 | end 58 | end 59 | 60 | defmacro access_token_fields do 61 | quote do 62 | ExOauth2Provider.Schema.fields(unquote(__MODULE__)) 63 | end 64 | end 65 | 66 | alias Ecto.Changeset 67 | alias ExOauth2Provider.{Config, Mixin.Scopes, Utils} 68 | 69 | @spec changeset(Ecto.Schema.t(), map(), keyword()) :: Changeset.t() 70 | def changeset(token, params, config \\ []) do 71 | server_scopes = server_scopes(token) 72 | 73 | token 74 | |> Changeset.cast(params, [:expires_in, :scopes]) 75 | |> validate_application_or_resource_owner() 76 | |> put_previous_refresh_token(params[:previous_refresh_token]) 77 | |> put_refresh_token(params[:use_refresh_token]) 78 | |> Scopes.put_scopes(server_scopes, config) 79 | |> Scopes.validate_scopes(server_scopes, config) 80 | |> put_token(config) 81 | end 82 | 83 | defp server_scopes(%{application: %{scopes: scopes}}), do: scopes 84 | defp server_scopes(_), do: nil 85 | 86 | defp validate_application_or_resource_owner(changeset) do 87 | cond do 88 | is_nil(Changeset.get_field(changeset, :application)) -> 89 | validate_resource_owner(changeset) 90 | 91 | is_nil(Changeset.get_field(changeset, :resource_owner)) -> 92 | validate_application(changeset) 93 | 94 | true -> 95 | changeset 96 | |> validate_resource_owner() 97 | |> validate_application() 98 | end 99 | end 100 | 101 | defp validate_application(changeset) do 102 | changeset 103 | |> Changeset.validate_required([:application]) 104 | |> Changeset.assoc_constraint(:application) 105 | end 106 | 107 | defp validate_resource_owner(changeset) do 108 | changeset 109 | |> Changeset.validate_required([:resource_owner]) 110 | |> Changeset.assoc_constraint(:resource_owner) 111 | end 112 | 113 | defp put_token(changeset, config) do 114 | changeset 115 | |> Changeset.change(%{token: gen_token(changeset, config)}) 116 | |> Changeset.validate_required([:token]) 117 | |> Changeset.unique_constraint(:token) 118 | end 119 | 120 | defp gen_token(%{data: %struct{}} = changeset, config) do 121 | created_at = Schema.__timestamp_for__(struct, :inserted_at) 122 | 123 | opts = 124 | changeset 125 | |> Changeset.apply_changes() 126 | |> Map.take([:resource_owner, :scopes, :application, :expires_in]) 127 | |> Map.put(:created_at, created_at) 128 | |> Enum.into([]) 129 | 130 | opts = Keyword.put(opts, :resource_owner_id, resource_owner_id(opts[:resource_owner])) 131 | 132 | case Config.access_token_generator(config) do 133 | nil -> Utils.generate_token(opts) 134 | {module, method} -> apply(module, method, [opts]) 135 | end 136 | end 137 | 138 | defp resource_owner_id(%{id: id}), do: id 139 | defp resource_owner_id(_), do: nil 140 | 141 | defp put_previous_refresh_token(changeset, nil), do: changeset 142 | defp put_previous_refresh_token(changeset, refresh_token), 143 | do: Changeset.change(changeset, %{previous_refresh_token: refresh_token.refresh_token}) 144 | 145 | defp put_refresh_token(changeset, true) do 146 | changeset 147 | |> Changeset.change(%{refresh_token: Utils.generate_token()}) 148 | |> Changeset.validate_required([:refresh_token]) 149 | end 150 | defp put_refresh_token(changeset, _), do: changeset 151 | end 152 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/applications/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Applications.Application do 2 | @moduledoc """ 3 | Handles the Ecto schema for application. 4 | 5 | ## Usage 6 | 7 | Configure `lib/my_project/oauth_applications/oauth_application.ex` the following way: 8 | 9 | defmodule MyApp.OauthApplications.OauthApplication do 10 | use Ecto.Schema 11 | use ExOauth2Provider.Applications.Application 12 | 13 | schema "oauth_applications" do 14 | application_fields() 15 | 16 | timestamps() 17 | end 18 | end 19 | 20 | ## Application owner 21 | 22 | By default the application owner will be will be the `:resource_owner` 23 | configuration setting. You can override this by overriding the `:owner` 24 | belongs to association: 25 | 26 | defmodule MyApp.OauthApplications.OauthApplication do 27 | use Ecto.Schema 28 | use ExOauth2Provider.Applications.Application 29 | 30 | schema "oauth_applications" do 31 | belongs_to :owner, MyApp.Users.User 32 | 33 | application_fields() 34 | 35 | timestamps() 36 | end 37 | end 38 | """ 39 | 40 | @type t :: Ecto.Schema.t() 41 | 42 | @doc false 43 | def attrs() do 44 | [ 45 | {:name, :string, [], null: false}, 46 | {:uid, :string, [], null: false}, 47 | {:secret, :string, [default: ""], null: false}, 48 | {:redirect_uri, :string, [], null: false}, 49 | {:scopes, :string, [default: ""], null: false}, 50 | ] 51 | end 52 | 53 | @doc false 54 | def assocs() do 55 | [ 56 | {:belongs_to, :owner, :users}, 57 | {:has_many, :access_tokens, :access_tokens, foreign_key: :application_id} 58 | ] 59 | end 60 | 61 | @doc false 62 | def indexes(), do: [{:uid, true}] 63 | 64 | @doc false 65 | defmacro __using__(config) do 66 | quote do 67 | use ExOauth2Provider.Schema, unquote(config) 68 | 69 | # For Phoenix integrations 70 | if Code.ensure_loaded?(Phoenix.Param), do: @derive {Phoenix.Param, key: :uid} 71 | 72 | import unquote(__MODULE__), only: [application_fields: 0] 73 | end 74 | end 75 | 76 | defmacro application_fields do 77 | quote do 78 | ExOauth2Provider.Schema.fields(unquote(__MODULE__)) 79 | end 80 | end 81 | 82 | alias Ecto.Changeset 83 | alias ExOauth2Provider.{RedirectURI, Utils} 84 | alias ExOauth2Provider.Mixin.Scopes 85 | 86 | @spec changeset(Ecto.Schema.t(), map(), keyword()) :: Changeset.t() 87 | def changeset(application, params, config \\ []) do 88 | application 89 | |> maybe_new_application_changeset(params, config) 90 | |> Changeset.cast(params, [:name, :secret, :redirect_uri, :scopes]) 91 | |> Changeset.validate_required([:name, :uid, :redirect_uri]) 92 | |> validate_secret_not_nil() 93 | |> Scopes.validate_scopes(nil, config) 94 | |> validate_redirect_uri(config) 95 | |> Changeset.unique_constraint(:uid) 96 | end 97 | 98 | defp validate_secret_not_nil(changeset) do 99 | case Changeset.get_field(changeset, :secret) do 100 | nil -> Changeset.add_error(changeset, :secret, "can't be blank") 101 | _ -> changeset 102 | end 103 | end 104 | 105 | defp maybe_new_application_changeset(application, params, config) do 106 | case Ecto.get_meta(application, :state) do 107 | :built -> new_application_changeset(application, params, config) 108 | :loaded -> application 109 | end 110 | end 111 | 112 | defp new_application_changeset(application, params, config) do 113 | application 114 | |> Changeset.cast(params, [:uid, :secret]) 115 | |> put_uid() 116 | |> put_secret() 117 | |> Scopes.put_scopes(nil, config) 118 | |> Changeset.assoc_constraint(:owner) 119 | end 120 | 121 | defp validate_redirect_uri(changeset, config) do 122 | changeset 123 | |> Changeset.get_field(:redirect_uri) 124 | |> Kernel.||("") 125 | |> String.split() 126 | |> Enum.reduce(changeset, fn url, changeset -> 127 | url 128 | |> RedirectURI.validate(config) 129 | |> case do 130 | {:error, error} -> Changeset.add_error(changeset, :redirect_uri, error) 131 | {:ok, _} -> changeset 132 | end 133 | end) 134 | end 135 | 136 | defp put_uid(%{changes: %{uid: _}} = changeset), do: changeset 137 | defp put_uid(%{} = changeset) do 138 | Changeset.change(changeset, %{uid: Utils.generate_token()}) 139 | end 140 | 141 | defp put_secret(%{changes: %{secret: _}} = changeset), do: changeset 142 | defp put_secret(%{} = changeset) do 143 | Changeset.change(changeset, %{secret: Utils.generate_token()}) 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/applications/applications.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Applications do 2 | @moduledoc """ 3 | The boundary for the applications system. 4 | """ 5 | 6 | import Ecto.Query 7 | alias Ecto.{Changeset, Schema} 8 | alias ExOauth2Provider.{AccessTokens, Applications.Application, Config} 9 | 10 | @doc """ 11 | Gets a single application by uid. 12 | 13 | Raises `Ecto.NoResultsError` if the Application does not exist. 14 | 15 | ## Examples 16 | 17 | iex> get_application!("c341a5c7b331ef076eb4954668d54f590e0009e06b81b100191aa22c93044f3d", otp_app: :my_app) 18 | %OauthApplication{} 19 | 20 | iex> get_application!("75d72f326a69444a9287ea264617058dbbfe754d7071b8eef8294cbf4e7e0fdc", otp_app: :my_app) 21 | ** (Ecto.NoResultsError) 22 | 23 | """ 24 | @spec get_application!(binary(), keyword()) :: Application.t() | no_return 25 | def get_application!(uid, config \\ []) do 26 | config 27 | |> Config.application() 28 | |> Config.repo(config).get_by!(uid: uid) 29 | end 30 | 31 | @doc """ 32 | Gets a single application for a resource owner. 33 | 34 | Raises `Ecto.NoResultsError` if the OauthApplication does not exist for resource owner. 35 | 36 | ## Examples 37 | 38 | iex> get_application_for!(owner, "c341a5c7b331ef076eb4954668d54f590e0009e06b81b100191aa22c93044f3d", otp_app: :my_app) 39 | %OauthApplication{} 40 | 41 | iex> get_application_for!(owner, "75d72f326a69444a9287ea264617058dbbfe754d7071b8eef8294cbf4e7e0fdc", otp_app: :my_app) 42 | ** (Ecto.NoResultsError) 43 | 44 | """ 45 | @spec get_application_for!(Schema.t(), binary(), keyword()) :: Application.t() | no_return 46 | def get_application_for!(resource_owner, uid, config \\ []) do 47 | config 48 | |> Config.application() 49 | |> Config.repo(config).get_by!(owner_id: resource_owner.id, uid: uid) 50 | end 51 | 52 | @doc """ 53 | Gets a single application by uid. 54 | 55 | ## Examples 56 | 57 | iex> get_application("c341a5c7b331ef076eb4954668d54f590e0009e06b81b100191aa22c93044f3d", otp_app: :my_app) 58 | %OauthApplication{} 59 | 60 | iex> get_application("75d72f326a69444a9287ea264617058dbbfe754d7071b8eef8294cbf4e7e0fdc", otp_app: :my_app) 61 | nil 62 | 63 | """ 64 | @spec get_application(binary(), keyword()) :: Application.t() | nil 65 | def get_application(uid, config \\ []) do 66 | config 67 | |> Config.application() 68 | |> Config.repo(config).get_by(uid: uid) 69 | end 70 | 71 | @doc """ 72 | Gets a single application by uid and secret. 73 | 74 | ## Examples 75 | 76 | iex> load_application("c341a5c7b331ef076eb4954668d54f590e0009e06b81b100191aa22c93044f3d", "SECRET", otp_app: :my_app) 77 | %OauthApplication{} 78 | 79 | iex> load_application("75d72f326a69444a9287ea264617058dbbfe754d7071b8eef8294cbf4e7e0fdc", "SECRET", otp_app: :my_app) 80 | nil 81 | 82 | """ 83 | @spec load_application(binary(), binary(), keyword()) :: Application.t() | nil 84 | def load_application(uid, secret, config \\ []) do 85 | config 86 | |> Config.application() 87 | |> Config.repo(config).get_by(uid: uid, secret: secret) 88 | end 89 | 90 | @doc """ 91 | Returns all applications for a owner. 92 | 93 | ## Examples 94 | 95 | iex> get_applications_for(resource_owner, otp_app: :my_app) 96 | [%OauthApplication{}, ...] 97 | 98 | """ 99 | @spec get_applications_for(Schema.t(), keyword()) :: [Application.t()] 100 | def get_applications_for(resource_owner, config \\ []) do 101 | config 102 | |> Config.application() 103 | |> where([a], a.owner_id == ^resource_owner.id) 104 | |> Config.repo(config).all() 105 | end 106 | 107 | @doc """ 108 | Gets all authorized applications for a resource owner. 109 | 110 | ## Examples 111 | 112 | iex> get_authorized_applications_for(owner, otp_app: :my_app) 113 | [%OauthApplication{},...] 114 | """ 115 | @spec get_authorized_applications_for(Schema.t(), keyword()) :: [Application.t()] 116 | def get_authorized_applications_for(resource_owner, config \\ []) do 117 | application_ids = 118 | resource_owner 119 | |> AccessTokens.get_authorized_tokens_for(config) 120 | |> Enum.map(&Map.get(&1, :application_id)) 121 | 122 | config 123 | |> Config.application() 124 | |> where([a], a.id in ^application_ids) 125 | |> Config.repo(config).all() 126 | end 127 | 128 | @doc """ 129 | Create application changeset. 130 | 131 | ## Examples 132 | 133 | iex> change_application(application, %{}, otp_app: :my_app) 134 | {:ok, %OauthApplication{}} 135 | 136 | """ 137 | @spec change_application(Application.t(), map(), keyword()) :: Changeset.t() 138 | def change_application(application, attrs \\ %{}, config \\ []) do 139 | Application.changeset(application, attrs, config) 140 | end 141 | 142 | @doc """ 143 | Creates an application. 144 | 145 | ## Examples 146 | 147 | iex> create_application(user, %{name: "App", redirect_uri: "http://example.com"}, otp_app: :my_app) 148 | {:ok, %OauthApplication{}} 149 | 150 | iex> create_application(user, %{name: ""}, otp_app: :my_app) 151 | {:error, %Ecto.Changeset{}} 152 | 153 | """ 154 | @spec create_application(Schema.t(), map(), keyword()) :: {:ok, Application.t()} | {:error, Changeset.t()} 155 | def create_application(owner, attrs \\ %{}, config \\ []) do 156 | config 157 | |> Config.application() 158 | |> struct(owner: owner) 159 | |> Application.changeset(attrs, config) 160 | |> Config.repo(config).insert() 161 | end 162 | 163 | @doc """ 164 | Updates an application. 165 | 166 | ## Examples 167 | 168 | iex> update_application(application, %{name: "Updated App"}, otp_app: :my_app) 169 | {:ok, %OauthApplication{}} 170 | 171 | iex> update_application(application, %{name: ""}, otp_app: :my_app) 172 | {:error, %Ecto.Changeset{}} 173 | 174 | """ 175 | @spec update_application(Application.t(), map(), keyword()) :: {:ok, Application.t()} | {:error, Changeset.t()} 176 | def update_application(application, attrs, config \\ []) do 177 | application 178 | |> Application.changeset(attrs, config) 179 | |> Config.repo(config).update() 180 | end 181 | 182 | @doc """ 183 | Deletes an application. 184 | 185 | ## Examples 186 | 187 | iex> delete_application(application, otp_app: :my_app) 188 | {:ok, %OauthApplication{}} 189 | 190 | iex> delete_application(application, otp_app: :my_app) 191 | {:error, %Ecto.Changeset{}} 192 | 193 | """ 194 | @spec delete_application(Application.t(), keyword()) :: {:ok, Application.t()} | {:error, Changeset.t()} 195 | def delete_application(application, config \\ []) do 196 | Config.repo(config).delete(application) 197 | end 198 | 199 | @doc """ 200 | Revokes all access tokens for an application and resource owner. 201 | 202 | ## Examples 203 | 204 | iex> revoke_all_access_tokens_for(application, resource_owner, otp_app: :my_app) 205 | {:ok, [ok: %OauthAccessToken{}]} 206 | 207 | """ 208 | @spec revoke_all_access_tokens_for(Application.t(), Schema.t(), keyword()) :: {:ok, [ok: AccessToken.t()]} | {:error, any()} 209 | def revoke_all_access_tokens_for(application, resource_owner, config \\ []) do 210 | repo = Config.repo(config) 211 | 212 | repo.transaction fn -> 213 | config 214 | |> Config.access_token() 215 | |> where([a], a.resource_owner_id == ^resource_owner.id) 216 | |> where([a], a.application_id == ^application.id) 217 | |> where([o], is_nil(o.revoked_at)) 218 | |> repo.all() 219 | |> Enum.map(&AccessTokens.revoke(&1, config)) 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Config do 2 | @moduledoc false 3 | 4 | @spec repo(keyword()) :: module() 5 | def repo(config) do 6 | get(config, :repo) || raise """ 7 | No `:repo` found in ExOauth2Provider configuration. 8 | 9 | Please set up the repo in your configuration: 10 | 11 | config #{inspect Keyword.get(config, :otp_app, :ex_oauth2_provider)}, ExOauth2Provider, 12 | repo: MyApp.Repo 13 | """ 14 | end 15 | 16 | @spec resource_owner(keyword()) :: module() 17 | def resource_owner(config), 18 | do: get(config, :resource_owner) || app_module(config, "Users", "User") 19 | 20 | defp app_module(config, context, module) do 21 | app = 22 | config 23 | |> Keyword.get(:otp_app) 24 | |> Kernel.||(raise "No `:otp_app` found in provided configuration. Please pass `:otp_app` in configuration.") 25 | |> app_base() 26 | 27 | Module.concat([app, context, module]) 28 | end 29 | 30 | @spec access_grant(keyword()) :: module() 31 | def access_grant(config), 32 | do: get_oauth_struct(config, :access_grant) 33 | 34 | @spec access_token(keyword()) :: module() 35 | def access_token(config), 36 | do: get_oauth_struct(config, :access_token) 37 | 38 | @spec application(keyword()) :: module() 39 | def application(config), 40 | do: get_oauth_struct(config, :application) 41 | 42 | defp get_oauth_struct(config, name, namespace \\ "oauth") do 43 | context = Macro.camelize("#{namespace}_#{name}s") 44 | module = Macro.camelize("#{namespace}_#{name}") 45 | 46 | config 47 | |> get(name) 48 | |> Kernel.||(app_module(config, context, module)) 49 | end 50 | 51 | @doc """ 52 | Fetches the context base module for the app. 53 | """ 54 | @spec app_base(atom()) :: module() 55 | def app_base(app) do 56 | case Application.get_env(app, :namespace, app) do 57 | ^app -> 58 | app 59 | |> to_string() 60 | |> Macro.camelize() 61 | |> List.wrap() 62 | |> Module.concat() 63 | 64 | mod -> 65 | mod 66 | end 67 | end 68 | 69 | # Define default access token scopes for your provider 70 | @spec default_scopes(keyword()) :: [binary()] 71 | def default_scopes(config), 72 | do: get(config, :default_scopes, []) 73 | 74 | # Combined scopes list for your provider 75 | @spec server_scopes(keyword()) :: [binary()] 76 | def server_scopes(config) do 77 | config 78 | |> default_scopes() 79 | |> Kernel.++(get(config, :optional_scopes, [])) 80 | end 81 | 82 | @spec redirect_uri_match_fun(keyword()) :: function() | nil 83 | def redirect_uri_match_fun(config), 84 | do: get(config, :redirect_uri_match_fun) 85 | 86 | @spec native_redirect_uri(keyword()) :: binary() 87 | def native_redirect_uri(config), 88 | do: get(config, :native_redirect_uri, "urn:ietf:wg:oauth:2.0:oob") 89 | 90 | @spec authorization_code_expires_in(keyword()) :: integer() 91 | def authorization_code_expires_in(config), 92 | do: get(config, :authorization_code_expires_in, 600) 93 | 94 | @spec access_token_expires_in(keyword()) :: integer() 95 | def access_token_expires_in(config), 96 | do: get(config, :access_token_expires_in, 7200) 97 | 98 | # Issue access tokens with refresh token (disabled by default) 99 | @spec use_refresh_token?(keyword()) :: boolean() 100 | def use_refresh_token?(config), 101 | do: get(config, :use_refresh_token, false) 102 | 103 | # Password auth method to use. Disabled by default. When set, it'll enable 104 | # password auth strategy. Set config as: 105 | # `password_auth: {MyModule, :my_auth_method}` 106 | @spec password_auth(keyword()) :: {atom(), atom()} | nil 107 | def password_auth(config), 108 | do: get(config, :password_auth) 109 | 110 | @spec refresh_token_revoked_on_use?(keyword()) :: boolean() 111 | def refresh_token_revoked_on_use?(config), 112 | do: get(config, :revoke_refresh_token_on_use, false) 113 | 114 | # Forces the usage of the HTTPS protocol in non-native redirect uris 115 | # (enabled by default in non-development environments). OAuth2 116 | # delegates security in communication to the HTTPS protocol so it is 117 | # wise to keep this enabled. 118 | @spec force_ssl_in_redirect_uri?(keyword()) :: boolean() 119 | def force_ssl_in_redirect_uri?(config), 120 | do: get(config, :force_ssl_in_redirect_uri, unquote(Mix.env != :dev)) 121 | 122 | # Use a custom access token generator 123 | @spec access_token_generator(keyword()) :: {atom(), atom()} | nil 124 | def access_token_generator(config), 125 | do: get(config, :access_token_generator) 126 | 127 | @spec access_token_response_body_handler(keyword()) :: {atom(), atom()} | nil 128 | def access_token_response_body_handler(config), 129 | do: get(config, :access_token_response_body_handler) 130 | 131 | @spec grant_flows(keyword()) :: [binary()] 132 | def grant_flows(config), 133 | do: get(config, :grant_flows, ~w(authorization_code client_credentials)) 134 | 135 | defp get(config, key, value \\ nil) do 136 | otp_app = Keyword.get(config, :otp_app) 137 | 138 | config 139 | |> get_from_config(key) 140 | |> get_from_app_env(otp_app, key) 141 | |> get_from_global_env(key) 142 | |> case do 143 | :not_found -> value 144 | value -> value 145 | end 146 | end 147 | 148 | defp get_from_config(config, key), do: Keyword.get(config, key, :not_found) 149 | 150 | defp get_from_app_env(:not_found, nil, _key), do: :not_found 151 | defp get_from_app_env(:not_found, otp_app, key) do 152 | otp_app 153 | |> Application.get_env(ExOauth2Provider, []) 154 | |> Keyword.get(key, :not_found) 155 | end 156 | defp get_from_app_env(value, _otp_app, _key), do: value 157 | 158 | defp get_from_global_env(:not_found, key) do 159 | :ex_oauth2_provider 160 | |> Application.get_env(ExOauth2Provider, []) 161 | |> Keyword.get(key, :not_found) 162 | end 163 | defp get_from_global_env(value, _key), do: value 164 | end 165 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/keys.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Keys do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec access_token_key(atom()) :: atom() 6 | def access_token_key(key \\ :default) do 7 | String.to_atom("#{base_key(key)}_access_token") 8 | end 9 | 10 | @doc false 11 | @spec base_key(binary()) :: atom() 12 | def base_key("ex_oauth2_provider_" <> _ = the_key) do 13 | String.to_atom(the_key) 14 | end 15 | 16 | @doc false 17 | @spec base_key(atom()) :: atom() 18 | def base_key(the_key) do 19 | String.to_atom("ex_oauth2_provider_#{the_key}") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/mixin/expirable.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Mixin.Expirable do 2 | @moduledoc false 3 | 4 | alias Ecto.Schema 5 | alias ExOauth2Provider.Schema, as: SchemaHelpers 6 | 7 | @doc """ 8 | Filter expired data. 9 | 10 | ## Examples 11 | 12 | iex> filter_expired(%Data{expires_in: 7200, inserted_at: ~N[2017-04-04 19:21:22.292762], ...}} 13 | %Data{} 14 | 15 | iex> filter_expired(%Data{expires_in: 10, inserted_at: ~N[2017-04-04 19:21:22.292762], ...}} 16 | nil 17 | """ 18 | @spec filter_expired(Schema.t()) :: Schema.t() | nil 19 | def filter_expired(data) do 20 | case is_expired?(data) do 21 | true -> nil 22 | false -> data 23 | end 24 | end 25 | 26 | @doc """ 27 | Checks if data has expired. 28 | 29 | ## Examples 30 | 31 | iex> is_expired?(%Data{expires_in: 7200, inserted_at: ~N[2017-04-04 19:21:22], ...}} 32 | false 33 | 34 | iex> is_expired?(%Data{expires_in: 10, inserted_at: ~N[2017-04-04 19:21:22], ...}} 35 | true 36 | 37 | iex> is_expired?(%Data{expires_in: nil}} 38 | false 39 | """ 40 | @spec is_expired?(Schema.t() | nil) :: boolean() 41 | def is_expired?(nil), do: true 42 | def is_expired?(%{expires_in: nil, inserted_at: _}), do: false 43 | def is_expired?(%struct{expires_in: expires_in, inserted_at: inserted_at}) do 44 | now = SchemaHelpers.__timestamp_for__(struct, :inserted_at) 45 | type = now.__struct__() 46 | 47 | inserted_at 48 | |> type.add(expires_in, :second) 49 | |> type.compare(now) 50 | |> Kernel.!=(:gt) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/mixin/revocable.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Mixin.Revocable do 2 | @moduledoc false 3 | 4 | alias Ecto.{Changeset, Schema} 5 | alias ExOauth2Provider.Config 6 | alias ExOauth2Provider.Schema, as: SchemaHelpers 7 | 8 | @doc """ 9 | Revoke data. 10 | 11 | ## Examples 12 | 13 | iex> revoke(data) 14 | {:ok, %Data{revoked_at: ~N[2017-04-04 19:21:22.292762], ...}} 15 | 16 | iex> revoke(invalid_data) 17 | {:error, %Ecto.Changeset{}} 18 | """ 19 | @spec revoke(Schema.t(), keyword()) :: {:ok, Schema.t()} | {:error, Changeset.t()} 20 | def revoke(data, config \\ []) do 21 | data 22 | |> revoke_query() 23 | |> case do 24 | nil -> {:ok, data} 25 | query -> Config.repo(config).update(query) 26 | end 27 | end 28 | 29 | @doc """ 30 | Same as `revoke/1` but raises error. 31 | """ 32 | @spec revoke!(Schema.t(), keyword()) :: Schema.t() | no_return 33 | def revoke!(data, config \\ []) do 34 | data 35 | |> revoke_query() 36 | |> case do 37 | nil -> data 38 | query -> Config.repo(config).update!(query) 39 | end 40 | end 41 | 42 | defp revoke_query(%struct{revoked_at: nil} = data) do 43 | Changeset.change(data, revoked_at: SchemaHelpers.__timestamp_for__(struct, :revoked_at)) 44 | end 45 | defp revoke_query(_data), do: nil 46 | 47 | @doc """ 48 | Filter revoked data. 49 | 50 | ## Examples 51 | 52 | iex> filter_revoked(%Data{revoked_at: nil, ...}} 53 | %Data{} 54 | 55 | iex> filter_revoked(%Data{revoked_at: ~N[2017-04-04 19:21:22.292762], ...}} 56 | nil 57 | """ 58 | @spec filter_revoked(Schema.t()) :: Schema.t() | nil 59 | def filter_revoked(data) do 60 | case is_revoked?(data) do 61 | true -> nil 62 | false -> data 63 | end 64 | end 65 | 66 | @doc """ 67 | Checks if data has been revoked. 68 | 69 | ## Examples 70 | 71 | iex> is_revoked?(%Data{revoked_at: nil, ...}} 72 | false 73 | 74 | iex> is_revoked?(%Data{revoked_at: ~N[2017-04-04 19:21:22.292762], ...}} 75 | true 76 | """ 77 | @spec is_revoked?(Schema.t()) :: boolean() 78 | def is_revoked?(%{revoked_at: nil}), do: false 79 | def is_revoked?(_), do: true 80 | end 81 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/mixin/scopes.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Mixin.Scopes do 2 | @moduledoc false 3 | alias ExOauth2Provider.{Config, Scopes} 4 | alias Ecto.Changeset 5 | 6 | @spec put_scopes(Changeset.t(), binary() | nil, keyword()) :: Changeset.t() 7 | def put_scopes(changeset, "", config), do: put_scopes(changeset, nil, config) 8 | def put_scopes(changeset, default_server_scopes, config) do 9 | changeset 10 | |> Changeset.get_field(:scopes) 11 | |> is_empty() 12 | |> case do 13 | true -> Changeset.change(changeset, %{scopes: parse_default_scope_string(default_server_scopes, config)}) 14 | _ -> changeset 15 | end 16 | end 17 | 18 | @spec validate_scopes(Changeset.t(), binary() | nil, keyword()) :: Changeset.t() 19 | def validate_scopes(changeset, "", config), do: validate_scopes(changeset, nil, config) 20 | def validate_scopes(changeset, server_scopes, config) do 21 | server_scopes = permitted_scopes(server_scopes, config) 22 | 23 | changeset 24 | |> Changeset.get_field(:scopes) 25 | |> can_use_scopes?(server_scopes, config) 26 | |> case do 27 | true -> changeset 28 | _ -> Changeset.add_error(changeset, :scopes, "not in permitted scopes list: #{inspect(server_scopes)}") 29 | end 30 | end 31 | 32 | defp is_empty(""), do: true 33 | defp is_empty(nil), do: true 34 | defp is_empty(_), do: false 35 | 36 | @spec parse_default_scope_string(binary() | [binary()] | nil, keyword()) :: binary() 37 | def parse_default_scope_string(nil, config), do: parse_default_scope_string("", config) 38 | def parse_default_scope_string(server_scopes, config) when is_binary(server_scopes) do 39 | server_scopes 40 | |> Scopes.to_list() 41 | |> parse_default_scope_string(config) 42 | end 43 | def parse_default_scope_string(server_scopes, config) do 44 | server_scopes 45 | |> Scopes.default_to_server_scopes(config) 46 | |> Scopes.filter_default_scopes(config) 47 | |> Scopes.to_string() 48 | end 49 | 50 | defp can_use_scopes?(scopes, server_scopes, config) when is_binary(scopes) do 51 | scopes 52 | |> Scopes.to_list() 53 | |> can_use_scopes?(server_scopes, config) 54 | end 55 | defp can_use_scopes?(scopes, server_scopes, config) when is_binary(server_scopes) do 56 | can_use_scopes?(scopes, Scopes.to_list(server_scopes), config) 57 | end 58 | defp can_use_scopes?(scopes, server_scopes, config) do 59 | server_scopes 60 | |> Scopes.default_to_server_scopes(config) 61 | |> Scopes.all?(scopes) 62 | end 63 | 64 | defp permitted_scopes(nil, config), 65 | do: Config.server_scopes(config) 66 | defp permitted_scopes(server_scopes, _config), 67 | do: server_scopes 68 | end 69 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Authorization do 2 | @moduledoc """ 3 | Handler for dealing with generating access grants. 4 | """ 5 | alias ExOauth2Provider.{ 6 | Authorization.Utils, 7 | Authorization.Utils.Response, 8 | Config, 9 | Utils.Error} 10 | alias Ecto.Schema 11 | 12 | @doc """ 13 | Check ExOauth2Provider.Authorization.Code for usage. 14 | """ 15 | @spec preauthorize(Schema.t(), map(), keyword()) :: Response.success() | Response.error() | Response.redirect() | Response.native_redirect() 16 | def preauthorize(resource_owner, request, config \\ []) do 17 | case validate_response_type(request, config) do 18 | {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) 19 | {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) 20 | {:ok, token_module} -> token_module.preauthorize(resource_owner, request, config) 21 | end 22 | end 23 | 24 | @doc """ 25 | Check ExOauth2Provider.Authorization.Code for usage. 26 | """ 27 | @spec authorize(Schema.t(), map(), keyword()) :: {:ok, binary()} | Response.error() | Response.redirect() | Response.native_redirect() 28 | def authorize(resource_owner, request, config \\ []) do 29 | case validate_response_type(request, config) do 30 | {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) 31 | {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) 32 | {:ok, token_module} -> token_module.authorize(resource_owner, request, config) 33 | end 34 | end 35 | 36 | @doc """ 37 | Check ExOauth2Provider.Authorization.Code for usage. 38 | """ 39 | @spec deny(Schema.t(), map(), keyword()) :: Response.error() | Response.redirect() 40 | def deny(resource_owner, request, config \\ []) do 41 | case validate_response_type(request, config) do 42 | {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) 43 | {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) 44 | {:ok, token_module} -> token_module.deny(resource_owner, request, config) 45 | end 46 | end 47 | 48 | defp unsupported_response_type(resource_owner, request, config), 49 | do: handle_error_response(resource_owner, request, Error.unsupported_response_type(), config) 50 | 51 | defp invalid_request(resource_owner, request, config), 52 | do: handle_error_response(resource_owner, request, Error.invalid_request(), config) 53 | 54 | defp handle_error_response(resource_owner, request, error, config) do 55 | resource_owner 56 | |> Utils.prehandle_request(request, config) 57 | |> Error.add_error(error) 58 | |> Response.error_response(config) 59 | end 60 | 61 | defp validate_response_type(%{"response_type" => type}, config) do 62 | type 63 | |> response_type_to_grant_flow() 64 | |> fetch_module(config) 65 | |> case do 66 | nil -> {:error, :invalid_response_type} 67 | mod -> {:ok, mod} 68 | end 69 | end 70 | defp validate_response_type(_, _config), do: {:error, :missing_response_type} 71 | 72 | defp response_type_to_grant_flow("code"), do: "authorization_code" 73 | defp response_type_to_grant_flow(_), do: nil 74 | 75 | defp fetch_module(grant_flow, config) do 76 | config 77 | |> Config.grant_flows() 78 | |> flow_can_be_used?(grant_flow) 79 | |> case do 80 | true -> flow_to_mod(grant_flow) 81 | false -> nil 82 | end 83 | end 84 | 85 | defp flow_can_be_used?(grant_flows, grant_flow) do 86 | Enum.member?(grant_flows, grant_flow) 87 | end 88 | 89 | defp flow_to_mod("authorization_code"), do: ExOauth2Provider.Authorization.Code 90 | defp flow_to_mod(_), do: nil 91 | end 92 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/authorization/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Authorization.Utils do 2 | @moduledoc false 3 | 4 | alias ExOauth2Provider.{Applications, Utils.Error} 5 | alias Ecto.Schema 6 | 7 | @doc false 8 | @spec prehandle_request(Schema.t(), map(), keyword()) :: {:ok, map()} | {:error, map()} 9 | def prehandle_request(resource_owner, request, config) do 10 | resource_owner 11 | |> new_params(request) 12 | |> load_client(config) 13 | |> set_defaults() 14 | end 15 | 16 | defp new_params(resource_owner, request) do 17 | {:ok, %{resource_owner: resource_owner, request: request}} 18 | end 19 | 20 | defp load_client({:ok, %{request: %{"client_id" => client_id}} = params}, config) do 21 | case Applications.get_application(client_id, config) do 22 | nil -> Error.add_error({:ok, params}, Error.invalid_client()) 23 | client -> {:ok, Map.put(params, :client, client)} 24 | end 25 | end 26 | defp load_client({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) 27 | 28 | defp set_defaults({:error, params}), do: {:error, params} 29 | defp set_defaults({:ok, %{request: request, client: client} = params}) do 30 | [redirect_uri | _rest] = String.split(client.redirect_uri) 31 | 32 | request = Map.new() 33 | |> Map.put("redirect_uri", redirect_uri) 34 | |> Map.put("scope", nil) 35 | |> Map.merge(request) 36 | 37 | {:ok, Map.put(params, :request, request)} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Authorization.Utils.Response do 2 | @moduledoc false 3 | 4 | alias ExOauth2Provider.{RedirectURI, Scopes, Utils} 5 | alias Ecto.Schema 6 | 7 | @type native_redirect :: {:native_redirect, %{code: binary()}} 8 | @type redirect :: {:redirect, binary()} 9 | @type error :: {:error, map(), integer()} 10 | @type success :: {:ok, Schema.t(), [binary()]} 11 | 12 | @doc false 13 | @spec error_response({:error, map()}, keyword()) :: error() | redirect() | native_redirect() 14 | def error_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) 15 | 16 | @doc false 17 | @spec preauthorize_response({:ok, map()} | {:error, map()}, keyword()) :: success() | error() | redirect() | native_redirect() 18 | def preauthorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) 19 | def preauthorize_response({:ok, %{client: client, request: %{"scope" => scopes}}}, _config), do: {:ok, client, Scopes.to_list(scopes)} 20 | def preauthorize_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) 21 | 22 | @doc false 23 | @spec authorize_response({:ok, map()} | {:error, map()}, keyword()) :: success() | error() | redirect() | native_redirect() 24 | def authorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) 25 | def authorize_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) 26 | 27 | @doc false 28 | @spec deny_response({:error, map()}, keyword()) :: error() | redirect() | native_redirect() 29 | def deny_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) 30 | 31 | defp build_response(%{request: request} = params, payload, config) do 32 | payload = add_state(payload, request) 33 | 34 | case can_redirect?(params, config) do 35 | true -> build_redirect_response(params, payload, config) 36 | _ -> build_standard_response(params, payload) 37 | end 38 | end 39 | 40 | defp add_state(payload, request) do 41 | case request["state"] do 42 | nil -> 43 | payload 44 | 45 | state -> 46 | %{"state" => state} 47 | |> Map.merge(payload) 48 | |> Utils.remove_empty_values() 49 | end 50 | end 51 | 52 | defp build_redirect_response(%{request: %{"redirect_uri" => redirect_uri}}, payload, config) do 53 | case RedirectURI.native_redirect_uri?(redirect_uri, config) do 54 | true -> {:native_redirect, payload} 55 | _ -> {:redirect, RedirectURI.uri_with_query(redirect_uri, payload)} 56 | end 57 | end 58 | 59 | defp build_standard_response(%{grant: _}, payload) do 60 | {:ok, payload} 61 | end 62 | defp build_standard_response(%{error: error, error_http_status: error_http_status}, _) do 63 | {:error, error, error_http_status} 64 | end 65 | defp build_standard_response(%{error: error}, _) do # For DB errors 66 | {:error, error, :bad_request} 67 | end 68 | 69 | defp can_redirect?(%{error: %{error: :invalid_redirect_uri}}, _config), do: false 70 | defp can_redirect?(%{error: %{error: :invalid_client}}, _config), do: false 71 | defp can_redirect?(%{error: %{error: _error}, request: %{"redirect_uri" => redirect_uri}}, config), do: !RedirectURI.native_redirect_uri?(redirect_uri, config) 72 | defp can_redirect?(%{error: _}, _config), do: false 73 | defp can_redirect?(%{request: %{}}, _config), do: true 74 | end 75 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token do 2 | @moduledoc """ 3 | Handler for dealing with generating access tokens. 4 | """ 5 | alias ExOauth2Provider.{ 6 | Config, 7 | Token.Revoke, 8 | Utils.Error} 9 | alias Ecto.Schema 10 | 11 | @doc """ 12 | Grants an access token based on grant_type strategy. 13 | 14 | ## Example 15 | 16 | ExOauth2Provider.Token.authorize(resource_owner, %{ 17 | "grant_type" => "invalid", 18 | "client_id" => "Jf5rM8hQBc", 19 | "client_secret" => "secret" 20 | }, otp_app: :my_app) 21 | 22 | ## Response 23 | 24 | {:error, %{error: error, error_description: description}, http_status} 25 | """ 26 | @spec grant(map(), keyword()) :: {:ok, Schema.t()} | {:error, map(), term} 27 | def grant(request, config \\ []) do 28 | case validate_grant_type(request, config) do 29 | {:error, :invalid_grant_type} -> Error.unsupported_grant_type() 30 | {:error, :missing_grant_type} -> Error.invalid_request() 31 | {:ok, token_module} -> token_module.grant(request, config) 32 | end 33 | end 34 | 35 | defp validate_grant_type(%{"grant_type" => type}, config) do 36 | type 37 | |> fetch_module(config) 38 | |> case do 39 | nil -> {:error, :invalid_grant_type} 40 | mod -> {:ok, mod} 41 | end 42 | end 43 | defp validate_grant_type(_, _config), do: {:error, :missing_grant_type} 44 | 45 | defp fetch_module(type, config) do 46 | config 47 | |> Config.grant_flows() 48 | |> grant_type_can_be_used?(type, config) 49 | |> case do 50 | true -> grant_type_to_mod(type) 51 | false -> nil 52 | end 53 | end 54 | 55 | defp grant_type_can_be_used?(_, "refresh_token", config), 56 | do: Config.use_refresh_token?(config) 57 | defp grant_type_can_be_used?(_, "password", config), 58 | do: not is_nil(Config.password_auth(config)) 59 | defp grant_type_can_be_used?(grant_flows, grant_type, _config) do 60 | Enum.member?(grant_flows, grant_type) 61 | end 62 | 63 | defp grant_type_to_mod("authorization_code"), do: ExOauth2Provider.Token.AuthorizationCode 64 | defp grant_type_to_mod("client_credentials"), do: ExOauth2Provider.Token.ClientCredentials 65 | defp grant_type_to_mod("password"), do: ExOauth2Provider.Token.Password 66 | defp grant_type_to_mod("refresh_token"), do: ExOauth2Provider.Token.RefreshToken 67 | defp grant_type_to_mod(_), do: nil 68 | 69 | @doc """ 70 | Revokes an access token as per http://tools.ietf.org/html/rfc7009 71 | 72 | ## Example 73 | ExOauth2Provider.Token.revoke(resource_owner, %{ 74 | "client_id" => "Jf5rM8hQBc", 75 | "client_secret" => "secret", 76 | "token" => "fi3S9u" 77 | }, otp_app: :my_app) 78 | 79 | ## Response 80 | 81 | {:ok, %{}} 82 | """ 83 | @spec revoke(map(), keyword()) :: {:ok, Schema.t()} | {:error, map(), term()} 84 | def revoke(request, config \\ []), do: Revoke.revoke(request, config) 85 | end 86 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/strategy/authorization_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.AuthorizationCode do 2 | @moduledoc """ 3 | Functions for dealing with authorization code strategy. 4 | """ 5 | alias ExOauth2Provider.{ 6 | AccessGrants, 7 | AccessTokens, 8 | Config, 9 | Token.Utils, 10 | Token.Utils.Response, 11 | Utils.Error} 12 | 13 | @doc """ 14 | Will grant access token by client credentials. 15 | 16 | ## Example 17 | ExOauth2Provider.Token.grant(%{ 18 | "code" => "1jf6a", 19 | "client_id" => "Jf5rM8hQBc", 20 | "client_secret" => "secret", 21 | "redirect_uri" => "https://example.com/", 22 | "grant_type" => "authorization_code" 23 | }, otp_app: :my_app) 24 | 25 | ## Response 26 | {:ok, access_token} 27 | {:error, %{error: error, error_description: description}, http_status} 28 | """ 29 | @spec grant(map(), keyword()) :: {:ok, map()} | {:error, map(), atom()} 30 | def grant(%{"grant_type" => "authorization_code"} = request, config \\ []) do 31 | {:ok, %{request: request}} 32 | |> Utils.load_client(config) 33 | |> load_active_access_grant(config) 34 | |> validate_redirect_uri() 35 | |> issue_access_token_by_grant(config) 36 | |> Response.response(config) 37 | end 38 | 39 | defp issue_access_token_by_grant({:error, params}, _config), do: {:error, params} 40 | defp issue_access_token_by_grant({:ok, %{access_grant: access_grant, request: _} = params}, config) do 41 | token_params = %{use_refresh_token: Config.use_refresh_token?(config)} 42 | 43 | result = Config.repo(config).transaction(fn -> 44 | access_grant 45 | |> revoke_grant(config) 46 | |> maybe_create_access_token(token_params, config) 47 | end) 48 | 49 | case result do 50 | {:ok, {:error, error}} -> Error.add_error({:ok, params}, error) 51 | {:ok, {:ok, access_token}} -> {:ok, Map.put(params, :access_token, access_token)} 52 | {:error, error} -> Error.add_error({:ok, params}, error) 53 | end 54 | end 55 | 56 | defp revoke_grant(%{revoked_at: nil} = access_grant, config), 57 | do: AccessGrants.revoke(access_grant, config) 58 | 59 | defp maybe_create_access_token({:error, _} = error, _token_params, _config), do: error 60 | defp maybe_create_access_token({:ok, %{resource_owner: resource_owner, application: application, scopes: scopes}}, token_params, config) do 61 | token_params = Map.merge(token_params, %{scopes: scopes, application: application}) 62 | 63 | resource_owner 64 | |> AccessTokens.get_token_for(application, scopes, config) 65 | |> case do 66 | nil -> AccessTokens.create_token(resource_owner, token_params, config) 67 | access_token -> {:ok, access_token} 68 | end 69 | end 70 | 71 | defp load_active_access_grant({:ok, %{client: client, request: %{"code" => code}} = params}, config) do 72 | client 73 | |> AccessGrants.get_active_grant_for(code, config) 74 | |> Config.repo(config).preload(:resource_owner) 75 | |> Config.repo(config).preload(:application) 76 | |> case do 77 | nil -> Error.add_error({:ok, params}, Error.invalid_grant()) 78 | access_grant -> {:ok, Map.put(params, :access_grant, access_grant)} 79 | end 80 | end 81 | defp load_active_access_grant({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_grant()) 82 | defp load_active_access_grant({:error, error}, _config), do: {:error, error} 83 | 84 | defp validate_redirect_uri({:error, params}), do: {:error, params} 85 | defp validate_redirect_uri({:ok, %{request: %{"redirect_uri" => redirect_uri}, access_grant: grant} = params}) do 86 | case grant.redirect_uri == redirect_uri do 87 | true -> {:ok, params} 88 | false -> Error.add_error({:ok, params}, Error.invalid_grant()) 89 | end 90 | end 91 | defp validate_redirect_uri({:ok, params}), do: Error.add_error({:ok, params}, Error.invalid_grant()) 92 | end 93 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/strategy/client_credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.ClientCredentials do 2 | @moduledoc """ 3 | Functions for dealing with client credentials strategy. 4 | """ 5 | alias ExOauth2Provider.{ 6 | AccessTokens, 7 | Token.Utils, 8 | Token.Utils.Response, 9 | Utils.Error} 10 | 11 | @doc """ 12 | Will grant access token by client credentials. 13 | 14 | ## Example 15 | ExOauth2Provider.Token.grant(%{ 16 | "grant_type" => "client_credentials", 17 | "client_id" => "Jf5rM8hQBc", 18 | "client_secret" => "secret" 19 | }, otp_app: :my_app) 20 | 21 | ## Response 22 | {:ok, access_token} 23 | {:error, %{error: error, error_description: description}, http_status} 24 | """ 25 | @spec grant(map(), keyword()) :: {:ok, map()} | {:error, map(), atom()} 26 | def grant(%{"grant_type" => "client_credentials"} = request, config \\ []) do 27 | {:ok, %{request: request}} 28 | |> Utils.load_client(config) 29 | |> issue_access_token_by_creds(config) 30 | |> Response.response(config) 31 | end 32 | 33 | defp issue_access_token_by_creds({:error, params}, _config), do: {:error, params} 34 | defp issue_access_token_by_creds({:ok, %{client: application, request: request} = params}, config) do 35 | scopes = request["scope"] 36 | token_params = %{ 37 | use_refresh_token: false, # client_credentials MUST NOT use refresh tokens 38 | scopes: scopes 39 | } 40 | 41 | application 42 | |> AccessTokens.get_application_token_for(scopes, config) 43 | |> case do 44 | nil -> AccessTokens.create_application_token(application, token_params, config) 45 | access_token -> {:ok, access_token} 46 | end 47 | |> case do 48 | {:ok, access_token} -> {:ok, Map.merge(params, %{access_token: access_token})} 49 | {:error, error} -> Error.add_error({:ok, params}, error) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/strategy/password.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Password do 2 | @moduledoc """ 3 | Functions for dealing with refresh token strategy. 4 | """ 5 | alias ExOauth2Provider.{ 6 | AccessTokens, 7 | Config, 8 | Scopes, 9 | Token.Utils, 10 | Token.Utils.Response, 11 | Utils.Error} 12 | 13 | @doc """ 14 | Will grant access token by password authentication. 15 | 16 | ## Example 17 | ExOauth2Provider.Token.grant(%{ 18 | "grant_type" => "password", 19 | "client_id" => "Jf5rM8hQBc", 20 | "client_secret" => "secret", 21 | "username" => "testuser@example.com", 22 | "password" => "secret" 23 | }, otp_app: :my_app) 24 | 25 | ## Response 26 | {:ok, access_token} 27 | {:error, %{error: error, error_description: description}, http_status} 28 | """ 29 | @spec grant(map(), keyword()) :: {:ok, map()} | {:error, map(), atom()} 30 | def grant(%{"grant_type" => "password"} = request, config \\ []) do 31 | {:ok, %{request: request}} 32 | |> get_password_auth_method(config) 33 | |> load_resource_owner() 34 | |> Utils.load_client(config) 35 | |> set_defaults() 36 | |> validate_scopes(config) 37 | |> issue_access_token(config) 38 | |> Response.response(config) 39 | end 40 | 41 | defp get_password_auth_method({:ok, params}, config) do 42 | case Config.password_auth(config) do 43 | {module, method} -> {:ok, Map.put(params, :password_auth, {module, method})} 44 | _ -> Error.add_error({:ok, params}, Error.unsupported_grant_type()) 45 | end 46 | end 47 | 48 | defp load_resource_owner({:error, params}), do: {:error, params} 49 | defp load_resource_owner({:ok, %{password_auth: {module, method}, request: %{"username" => username, "password" => password}} = params}) do 50 | case apply(module, method, [username, password]) do 51 | {:ok, resource_owner} -> 52 | {:ok, Map.put(params, :resource_owner, resource_owner)} 53 | 54 | {:error, reason} -> 55 | {:error, Map.merge(params, %{error: :unauthorized, error_description: reason, error_http_status: :unauthorized})} 56 | end 57 | end 58 | defp load_resource_owner({:ok, params}), do: Error.add_error({:ok, params}, Error.invalid_request()) 59 | 60 | defp issue_access_token({:error, params}, _config), do: {:error, params} 61 | defp issue_access_token({:ok, %{client: application, resource_owner: resource_owner, request: request} = params}, config) do 62 | scopes = request["scope"] 63 | token_params = %{use_refresh_token: Config.use_refresh_token?(config), scopes: scopes, application: application} 64 | 65 | resource_owner 66 | |> AccessTokens.get_token_for(application, scopes, config) 67 | |> case do 68 | nil -> AccessTokens.create_token(resource_owner, token_params, config) 69 | access_token -> {:ok, access_token} 70 | end 71 | |> case do 72 | {:ok, access_token} -> {:ok, Map.merge(params, %{access_token: access_token})} 73 | {:error, error} -> Error.add_error({:ok, params}, error) 74 | end 75 | end 76 | 77 | defp set_defaults({:error, params}), do: {:error, params} 78 | defp set_defaults({:ok, %{request: request, client: client} = params}) do 79 | scopes = Map.get(params.request, "scope", client.scopes) 80 | request = Map.put(request, "scope", scopes) 81 | 82 | {:ok, Map.put(params, :request, request)} 83 | end 84 | 85 | defp validate_scopes({:error, params}, _config), do: {:error, params} 86 | defp validate_scopes({:ok, %{request: %{"scope" => scopes}, client: client} = params}, config) do 87 | scopes = Scopes.to_list(scopes) 88 | server_scopes = 89 | client.scopes 90 | |> Scopes.to_list() 91 | |> Scopes.default_to_server_scopes(config) 92 | 93 | case Scopes.all?(server_scopes, scopes) do 94 | true -> {:ok, params} 95 | false -> Error.add_error({:ok, params}, Error.invalid_scopes()) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/strategy/refresh_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.RefreshToken do 2 | @moduledoc """ 3 | Functions for dealing with refresh token strategy. 4 | """ 5 | 6 | alias ExOauth2Provider.{ 7 | AccessTokens, 8 | Config, 9 | Token.Utils, 10 | Token.Utils.Response, 11 | Utils.Error} 12 | 13 | @doc """ 14 | Will grant access token by refresh token. 15 | 16 | ## Example 17 | ExOauth2Provider.Token.authorize(%{ 18 | "grant_type" => "refresh_token", 19 | "client_id" => "Jf5rM8hQBc", 20 | "client_secret" => "secret", 21 | "refresh_token" => "1jf6a" 22 | }, otp_app: :my_app) 23 | 24 | ## Response 25 | {:ok, access_token} 26 | {:error, %{error: error, error_description: description}, http_status} 27 | """ 28 | @spec grant(map(), keyword()) :: {:ok, map()} | {:error, map(), atom()} 29 | def grant(%{"grant_type" => "refresh_token"} = request, config \\ []) do 30 | {:ok, %{request: request}} 31 | |> Utils.load_client(config) 32 | |> load_access_token_by_refresh_token(config) 33 | |> issue_access_token_by_refresh_token(config) 34 | |> Response.response(config) 35 | end 36 | 37 | defp load_access_token_by_refresh_token({:ok, %{client: client, request: %{"refresh_token" => refresh_token}} = params}, config) do 38 | access_token = 39 | client 40 | |> AccessTokens.get_by_refresh_token_for(refresh_token, config) 41 | |> Config.repo(config).preload(:resource_owner) 42 | |> Config.repo(config).preload(:application) 43 | 44 | case access_token do 45 | nil -> Error.add_error({:ok, params}, Error.invalid_request()) 46 | access_token -> {:ok, Map.put(params, :refresh_token, access_token)} 47 | end 48 | end 49 | defp load_access_token_by_refresh_token(params, _config), do: Error.add_error(params, Error.invalid_request()) 50 | 51 | defp issue_access_token_by_refresh_token({:error, params}, _config), do: {:error, params} 52 | defp issue_access_token_by_refresh_token({:ok, %{refresh_token: refresh_token, request: _} = params}, config) do 53 | result = Config.repo(config).transaction(fn -> 54 | token_params = token_params(refresh_token, config) 55 | 56 | refresh_token 57 | |> revoke_access_token(config) 58 | |> case do 59 | {:ok, %{resource_owner: resource_owner}} -> AccessTokens.create_token(resource_owner, token_params, config) 60 | {:error, error} -> {:error, error} 61 | end 62 | end) 63 | 64 | case result do 65 | {:ok, {:error, error}} -> Error.add_error({:ok, params}, error) 66 | {:ok, {:ok, access_token}} -> {:ok, Map.merge(params, %{access_token: access_token})} 67 | {:error, error} -> Error.add_error({:ok, params}, error) 68 | end 69 | end 70 | 71 | defp token_params(%{scopes: scopes, application: application} = refresh_token, config) do 72 | params = %{scopes: scopes, application: application, use_refresh_token: true} 73 | 74 | case Config.refresh_token_revoked_on_use?(config) do 75 | true -> Map.put(params, :previous_refresh_token, refresh_token) 76 | false -> params 77 | end 78 | end 79 | 80 | defp revoke_access_token(refresh_token, config) do 81 | cond do 82 | not Config.refresh_token_revoked_on_use?(config) -> 83 | {:ok, refresh_token} 84 | 85 | AccessTokens.is_revoked?(refresh_token) -> 86 | {:error, Error.invalid_request()} 87 | 88 | true -> 89 | AccessTokens.revoke(refresh_token, config) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/strategy/revoke.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Revoke do 2 | @moduledoc """ 3 | Functions for dealing with revocation. 4 | """ 5 | alias ExOauth2Provider.{ 6 | AccessTokens, 7 | Config, 8 | Token.Utils, 9 | Token.Utils.Response, 10 | Utils.Error} 11 | 12 | @doc """ 13 | Revokes access token. 14 | 15 | The authorization server, if applicable, first authenticates the client 16 | and checks its ownership of the provided token. 17 | 18 | ExOauth2Provider does not use the token_type_hint logic described in the 19 | RFC 7009 due to the refresh token implementation that is a field in 20 | the access token schema. 21 | 22 | ## Example confidential client 23 | ExOauth2Provider.Token.revoke(%{ 24 | "client_id" => "Jf5rM8hQBc", 25 | "client_secret" => "secret", 26 | "token" => "fi3S9u" 27 | }, otp_app: :my_app) 28 | 29 | ## Response 30 | {:ok, %{}} 31 | 32 | ## Example public client 33 | ExOauth2Provider.Token.revoke(%{ 34 | "token" => "fi3S9u" 35 | }, otp_app: :my_app) 36 | 37 | ## Response 38 | {:ok, %{}} 39 | """ 40 | @spec revoke(map(), keyword()) :: {:ok, map()} | {:error, map(), atom()} 41 | def revoke(request, config \\ []) do 42 | {:ok, %{request: request}} 43 | |> load_client_if_presented(config) 44 | |> return_error() 45 | |> load_access_token(config) 46 | |> validate_request() 47 | |> revoke_token(config) 48 | |> Response.revocation_response(config) 49 | end 50 | 51 | defp load_client_if_presented({:ok, %{request: %{"client_id" => _}} = params}, config), 52 | do: Utils.load_client({:ok, params}, config) 53 | defp load_client_if_presented({:ok, params}, _config), do: {:ok, params} 54 | 55 | defp load_access_token({:error, %{error: _} = params}, _config), do: {:error, params} 56 | defp load_access_token({:ok, %{request: %{"token" => _}} = params}, config) do 57 | {:ok, params} 58 | |> get_access_token(config) 59 | |> get_refresh_token(config) 60 | |> preload_token_associations(config) 61 | end 62 | defp load_access_token({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) 63 | 64 | defp get_access_token({:ok, %{request: %{"token" => token}} = params}, config) do 65 | token 66 | |> AccessTokens.get_by_token(config) 67 | |> case do 68 | nil -> Error.add_error({:ok, params}, Error.invalid_request()) 69 | access_token -> {:ok, Map.put(params, :access_token, access_token)} 70 | end 71 | end 72 | 73 | defp get_refresh_token({:ok, %{access_token: _} = params}, _config), do: {:ok, params} 74 | defp get_refresh_token({:error, %{error: _} = params}, _config), do: {:error, params} 75 | defp get_refresh_token({:ok, %{request: %{"token" => token}} = params}, config) do 76 | token 77 | |> AccessTokens.get_by_refresh_token(config) 78 | |> case do 79 | nil -> Error.add_error({:ok, params}, Error.invalid_request()) 80 | access_token -> {:ok, Map.put(params, :access_token, access_token)} 81 | end 82 | end 83 | 84 | defp preload_token_associations({:error, params}, _config), do: {:error, params} 85 | defp preload_token_associations({:ok, %{access_token: access_token} = params}, config) do 86 | {:ok, Map.put(params, :access_token, Config.repo(config).preload(access_token, :application))} 87 | end 88 | 89 | defp validate_request({:error, params}), do: {:error, params} 90 | defp validate_request({:ok, params}) do 91 | {:ok, params} 92 | |> validate_permissions() 93 | |> validate_accessible() 94 | end 95 | 96 | # This will verify permissions on the access token and client. 97 | # 98 | # OAuth 2.0 Section 2.1 defines two client types, "public" & "confidential". 99 | # Public clients (as per RFC 7009) do not require authentication whereas 100 | # confidential clients must be authenticated for their token revocation. 101 | # 102 | # Once a confidential client is authenticated, it must be authorized to 103 | # revoke the provided access or refresh token. This ensures one client 104 | # cannot revoke another's tokens. 105 | # 106 | # ExOauth2Provider determines the client type implicitly via the presence of the 107 | # OAuth client associated with a given access or refresh token. Since public 108 | # clients authenticate the resource owner via "password" or "implicit" grant 109 | # types, they set the application_id as null (since the claim cannot be 110 | # verified). 111 | # 112 | # https://tools.ietf.org/html/rfc6749#section-2.1 113 | # https://tools.ietf.org/html/rfc7009 114 | 115 | # Client is public, authentication unnecessary 116 | defp validate_permissions({:ok, %{access_token: %{application_id: nil}} = params}), do: {:ok, params} 117 | # Client is confidential, therefore client authentication & authorization is required 118 | defp validate_permissions({:ok, %{access_token: %{application_id: _id}} = params}), do: validate_ownership({:ok, params}) 119 | 120 | defp validate_ownership({:ok, %{access_token: %{application_id: application_id}, client: %{id: client_id}} = params}) when application_id == client_id, do: {:ok, params} 121 | defp validate_ownership({:ok, params}), do: Error.add_error({:ok, params}, Error.invalid_request()) 122 | 123 | defp validate_accessible({:error, params}), do: {:error, params} 124 | defp validate_accessible({:ok, %{access_token: access_token} = params}) do 125 | case AccessTokens.is_accessible?(access_token) do 126 | true -> {:ok, params} 127 | false -> Error.add_error({:ok, params}, Error.invalid_request()) 128 | end 129 | end 130 | 131 | defp revoke_token({:error, params}, _config), do: {:error, params} 132 | defp revoke_token({:ok, %{access_token: access_token} = params}, config) do 133 | case AccessTokens.revoke(access_token, config) do 134 | {:ok, _} -> {:ok, params} 135 | {:error, _} -> Error.add_error({:ok, params}, Error.invalid_request()) 136 | end 137 | end 138 | 139 | defp return_error({:error, params}), do: {:error, Map.put(params, :should_return_error, true)} 140 | defp return_error({:ok, params}), do: {:ok, params} 141 | end 142 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Utils do 2 | @moduledoc false 3 | 4 | alias ExOauth2Provider.{Applications, Utils.Error} 5 | 6 | @doc false 7 | @spec load_client({:ok, map()}, keyword()) :: {:ok, map()} | {:error, map()} 8 | def load_client({:ok, %{request: request = %{"client_id" => client_id}} = params}, config) do 9 | client_secret = Map.get(request, "client_secret", "") 10 | 11 | case Applications.load_application(client_id, client_secret, config) do 12 | nil -> Error.add_error({:ok, params}, Error.invalid_client()) 13 | client -> {:ok, Map.merge(params, %{client: client})} 14 | end 15 | end 16 | def load_client({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) 17 | def load_client({:error, params}, _config), do: {:error, params} 18 | end 19 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/token/utils/response.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Utils.Response do 2 | @moduledoc false 3 | 4 | alias ExOauth2Provider.Config 5 | 6 | @doc false 7 | @spec response({:ok, map()} | {:error, map()}, keyword()) :: {:ok, map()} | {:error, map(), atom()} 8 | def response({:ok, %{access_token: token}}, config), do: build_response(%{access_token: token}, config) 9 | def response({:error, %{error: _} = params}, config), do: build_response(params, config) 10 | 11 | @doc false 12 | @spec revocation_response({:ok, map()} | {:error, map()}, keyword()) :: {:ok, map()} | {:error, map(), atom()} 13 | def revocation_response({:error, %{should_return_error: true} = params}, config), do: response({:error, params}, config) 14 | def revocation_response({_any, _params}, _config), do: {:ok, %{}} 15 | 16 | defp build_response(%{access_token: access_token}, config) do 17 | body = %{access_token: access_token.token, 18 | # Access Token type: Bearer. 19 | # @see https://tools.ietf.org/html/rfc6750 20 | # The OAuth 2.0 Authorization Framework: Bearer Token Usage 21 | token_type: "bearer", 22 | expires_in: access_token.expires_in, 23 | refresh_token: access_token.refresh_token, 24 | scope: access_token.scopes, 25 | created_at: access_token.inserted_at 26 | } |> customize_access_token_response(access_token, config) 27 | {:ok, body} 28 | end 29 | defp build_response(%{error: error, error_http_status: error_http_status}, _config) do 30 | {:error, error, error_http_status} 31 | end 32 | defp build_response(%{error: error}, _config) do # For DB errors 33 | {:error, error, :bad_request} 34 | end 35 | 36 | defp customize_access_token_response(response_body, access_token, config) do 37 | case Config.access_token_response_body_handler(config) do 38 | {module, method} -> apply(module, method, [response_body, access_token]) 39 | _ -> response_body 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/oauth2/utils/error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Utils.Error do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec add_error({:ok, map()} | {:error, map()}, {:error, map(), atom()}) :: {:error, map()} 6 | def add_error({:error, params}, _), do: {:error, params} 7 | def add_error({:ok, params}, {:error, error, http_status}) do 8 | {:error, Map.merge(params, %{error: error, error_http_status: http_status})} 9 | end 10 | 11 | @spec server_error() :: {:error, map(), atom()} 12 | def server_error do 13 | msg = "The authorization server encountered an unexpected condition which prevented it from fulfilling the request." 14 | {:error, %{error: :internal_server_error, error_description: msg}, :internal_server_error} 15 | end 16 | 17 | @doc false 18 | @spec invalid_request() :: {:error, map(), atom()} 19 | def invalid_request do 20 | msg = "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." 21 | {:error, %{error: :invalid_request, error_description: msg}, :bad_request} 22 | end 23 | 24 | @doc false 25 | @spec invalid_client() :: {:error, map(), atom()} 26 | def invalid_client do 27 | msg = "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 28 | {:error, %{error: :invalid_client, error_description: msg}, :unprocessable_entity} 29 | end 30 | 31 | @doc false 32 | @spec invalid_grant() :: {:error, map(), atom()} 33 | def invalid_grant do 34 | msg = "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client." 35 | {:error, %{error: :invalid_grant, error_description: msg}, :unprocessable_entity} 36 | end 37 | 38 | @doc false 39 | @spec unsupported_grant_type() :: {:error, map(), atom()} 40 | def unsupported_grant_type do 41 | msg = "The authorization grant type is not supported by the authorization server." 42 | {:error, %{error: :unsupported_grant_type, error_description: msg}, :unprocessable_entity} 43 | end 44 | 45 | @doc false 46 | @spec invalid_scopes() :: {:error, map(), atom()} 47 | def invalid_scopes do 48 | msg = "The requested scope is invalid, unknown, or malformed." 49 | {:error, %{error: :invalid_scope, error_description: msg}, :unprocessable_entity} 50 | end 51 | 52 | @doc false 53 | @spec invalid_redirect_uri() :: {:error, map(), atom()} 54 | def invalid_redirect_uri do 55 | msg = "The redirect uri included is not valid." 56 | {:error, %{error: :invalid_redirect_uri, error_description: msg}, :unprocessable_entity} 57 | end 58 | 59 | @doc false 60 | @spec access_denied() :: {:error, map(), atom()} 61 | def access_denied do 62 | msg = "The resource owner or authorization server denied the request." 63 | {:error, %{error: :access_denied, error_description: msg}, :unauthorized} 64 | end 65 | 66 | @doc false 67 | @spec unsupported_response_type() :: {:error, map(), atom()} 68 | def unsupported_response_type do 69 | msg = "The authorization server does not support this response type." 70 | {:error, %{error: :unsupported_response_type, error_description: msg}, :unprocessable_entity} 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug do 2 | @moduledoc """ 3 | ExOauth2Provider.Plug contains functions that assist with interacting with 4 | ExOauth2Provider via Plugs. 5 | 6 | ExOauth2Provider.Plug is not itself a plug. 7 | 8 | Use the helpers to look up current_access_token and current_resource_owner. 9 | 10 | ## Example 11 | ExOauth2Provider.Plug.current_access_token(conn) 12 | ExOauth2Provider.Plug.current_resource_owner(conn) 13 | """ 14 | 15 | alias ExOauth2Provider.{Keys, AccessTokens.AccessToken} 16 | alias Plug.Conn 17 | 18 | @doc """ 19 | Check if a request is authenticated 20 | """ 21 | @spec authenticated?(Conn.t(), atom()) :: boolean() 22 | def authenticated?(conn, type \\ :default) do 23 | case get_current_access_token(conn, type) do 24 | {:error, _error} -> false 25 | {:ok, _access_token} -> true 26 | end 27 | end 28 | 29 | @doc """ 30 | Fetch the currently authenticated resource if loaded, 31 | optionally located at a key 32 | """ 33 | @spec current_resource_owner(Conn.t(), atom()) :: map() | nil 34 | def current_resource_owner(conn, the_key \\ :default) do 35 | conn 36 | |> current_access_token(the_key) 37 | |> case do 38 | nil -> nil 39 | access_token -> access_token.resource_owner 40 | end 41 | end 42 | 43 | @doc """ 44 | Fetch the currently verified token from the request. 45 | Optionally located at a key 46 | """ 47 | @spec current_access_token(Conn.t(), atom()) :: AccessToken.t() | nil 48 | def current_access_token(conn, the_key \\ :default) do 49 | case get_current_access_token(conn, the_key) do 50 | {:error, _error} -> nil 51 | {:ok, access_token} -> access_token 52 | end 53 | end 54 | 55 | @doc false 56 | @spec get_current_access_token(Conn.t(), atom()) :: {:ok, AccessToken.t()} | {:error, term()} 57 | def get_current_access_token(conn, the_key \\ :default) do 58 | case conn.private[Keys.access_token_key(the_key)] do 59 | {:ok, access_token} -> {:ok, access_token} 60 | {:error, error} -> {:error, error} 61 | _ -> {:error, :no_session} 62 | end 63 | end 64 | 65 | @doc false 66 | @spec set_current_access_token(Conn.t(), {:ok, map()} | {:error, any()}, atom()) :: Conn.t() 67 | def set_current_access_token(conn, access_token, the_key \\ :default) do 68 | Conn.put_private(conn, Keys.access_token_key(the_key), access_token) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/plug/ensure_authenticated.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.EnsureAuthenticated do 2 | @moduledoc """ 3 | This plug ensures that the request has been authenticated with an access token. 4 | 5 | If one is not found, the `unauthenticated/2` function is invoked with the 6 | `Plug.Conn.t` object and its params. 7 | 8 | ## Example 9 | 10 | # Will call the unauthenticated/2 function on your handler 11 | plug ExOauth2Provider.Plug.EnsureAuthenticated, handler: SomeModule 12 | 13 | # look in the :secret location. You can also do simple claim checks: 14 | plug ExOauth2Provider.Plug.EnsureAuthenticated, handler: SomeModule, key: :secret 15 | 16 | plug ExOauth2Provider.Plug.EnsureAuthenticated, handler: SomeModule, typ: "access" 17 | 18 | If the handler option is not passed, `ExOauth2Provider.Plug.ErrorHandler` will provide 19 | the default behavior. 20 | """ 21 | alias Plug.Conn 22 | alias ExOauth2Provider.Plug 23 | 24 | @doc false 25 | @spec init(keyword()) :: keyword() 26 | def init(opts), do: opts 27 | 28 | @doc false 29 | @spec call(Conn.t(), keyword()) :: map() 30 | def call(conn, opts) do 31 | key = Keyword.get(opts, :key, :default) 32 | 33 | conn 34 | |> Plug.get_current_access_token(key) 35 | |> handle_authentication(conn, opts) 36 | end 37 | 38 | defp handle_authentication({:ok, _}, conn, _opts), do: conn 39 | defp handle_authentication({:error, reason}, %{params: params} = conn, opts) do 40 | params = Map.put(params, :reason, reason) 41 | module = Keyword.get(opts, :handler, ExOauth2Provider.Plug.ErrorHandler) 42 | 43 | conn 44 | |> Conn.assign(:ex_oauth2_provider_failure, reason) 45 | |> Conn.halt() 46 | |> module.unauthenticated(params) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/plug/ensure_scopes.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.EnsureScopes do 2 | @moduledoc """ 3 | Use this plug to ensure that there are the correct scopes on 4 | the token found on the connection. 5 | 6 | ### Example 7 | alias ExOauth2Provider.Plug.EnsureScopes 8 | 9 | # With custom handler 10 | plug EnsureScopes, scopes: ~w(read write), handler: SomeMod, 11 | 12 | # item:read AND item:write scopes AND :profile scope 13 | plug EnsureScopes, scopes: ~(item:read item:write profile) 14 | 15 | # iteam:read AND item: write scope OR :profile for the default set 16 | plug EnsureScopes, one_of: [~(item:read item:write), 17 | ~(profile)] 18 | 19 | # item :read AND :write for the token located in the :secret location 20 | plug EnsureScopes, key: :secret, scopes: ~(read :write) 21 | 22 | If the handler option is not passed, `ExOauth2Provider.Plug.ErrorHandler` 23 | will provide the default behavior. 24 | """ 25 | 26 | require Logger 27 | 28 | alias Plug.Conn 29 | alias ExOauth2Provider.{Plug, Scopes} 30 | 31 | @doc false 32 | @spec init(keyword()) :: keyword() 33 | def init(opts), do: opts 34 | 35 | @doc false 36 | @spec call(Conn.t(), keyword()) :: map() 37 | def call(conn, opts) do 38 | key = Keyword.get(opts, :key, :default) 39 | 40 | conn 41 | |> Plug.current_access_token(key) 42 | |> check_scopes(conn, opts) 43 | |> handle_error() 44 | end 45 | 46 | defp check_scopes(nil, conn, opts), do: {:error, conn, opts} 47 | defp check_scopes(token, conn, opts) do 48 | scopes_set = fetch_scopes(opts) 49 | 50 | case matches_any_scopes_set?(token, scopes_set) do 51 | true -> {:ok, conn, opts} 52 | false -> {:error, conn, opts} 53 | end 54 | end 55 | 56 | defp fetch_scopes(opts) do 57 | fetch_scopes(opts, Keyword.get(opts, :one_of)) 58 | end 59 | 60 | defp fetch_scopes(opts, nil), do: [Keyword.get(opts, :scopes)] 61 | defp fetch_scopes(_opts, scopes), do: scopes 62 | 63 | defp matches_any_scopes_set?(_, []), do: true 64 | defp matches_any_scopes_set?(access_token, scopes_sets) do 65 | Enum.any?(scopes_sets, &matches_scopes?(access_token, &1)) 66 | end 67 | 68 | defp matches_scopes?(access_token, required_scopes) do 69 | access_token 70 | |> Scopes.from_access_token() 71 | |> Scopes.all?(required_scopes) 72 | end 73 | 74 | defp handle_error({:ok, conn, _}), do: conn 75 | defp handle_error({:error, %Conn{params: params} = conn, opts}) do 76 | module = Keyword.get(opts, :handler, Plug.ErrorHandler) 77 | params = Map.put(params, :reason, :unauthorized) 78 | 79 | conn 80 | |> Conn.assign(:ex_oauth2_provider_failure, :unauthorized) 81 | |> Conn.halt() 82 | |> module.unauthorized(params) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/plug/error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.ErrorHandler do 2 | @moduledoc """ 3 | A default error handler that can be used for failed authentication 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | @callback unauthenticated(Conn.t(), map()) :: Conn.t() 9 | @callback unauthorized(Conn.t(), map()) :: Conn.t() 10 | @callback no_resource(Conn.t(), map()) :: Conn.t() 11 | 12 | @doc false 13 | @spec unauthenticated(Conn.t(), map()) :: Conn.t() 14 | def unauthenticated(conn, _params) do 15 | respond(conn, response_type(conn), 401, "Unauthenticated") 16 | end 17 | 18 | @doc false 19 | @spec unauthorized(Conn.t(), map()) :: Conn.t() 20 | def unauthorized(conn, _params) do 21 | respond(conn, response_type(conn), 403, "Unauthorized") 22 | end 23 | 24 | @doc false 25 | @spec no_resource(Conn.t(), map()) :: Conn.t() 26 | def no_resource(conn, _params) do 27 | respond(conn, response_type(conn), 403, "Unauthorized") 28 | end 29 | 30 | @doc false 31 | @spec already_authenticated(Conn.t(), map()) :: Conn.t() 32 | def already_authenticated(conn, _params), do: Conn.halt(conn) 33 | 34 | defp respond(conn, :json, status, msg) do 35 | conn 36 | |> Conn.configure_session(drop: true) 37 | |> Conn.put_resp_content_type("application/json") 38 | |> Conn.send_resp(status, Jason.encode!(%{errors: [msg]})) 39 | rescue ArgumentError -> 40 | conn 41 | |> Conn.put_resp_content_type("application/json") 42 | |> Conn.send_resp(status, Jason.encode!(%{errors: [msg]})) 43 | end 44 | 45 | defp respond(conn, :html, status, msg) do 46 | conn 47 | |> Conn.configure_session(drop: true) 48 | |> Conn.put_resp_content_type("text/plain") 49 | |> Conn.send_resp(status, msg) 50 | rescue ArgumentError -> 51 | conn 52 | |> Conn.put_resp_content_type("text/plain") 53 | |> Conn.send_resp(status, msg) 54 | end 55 | 56 | defp response_type(conn) do 57 | accept = accept_header(conn) 58 | 59 | case Regex.match?(~r/json/, accept) do 60 | true -> :json 61 | false -> :html 62 | end 63 | end 64 | 65 | defp accept_header(conn) do 66 | conn 67 | |> Conn.get_req_header("accept") 68 | |> List.first() 69 | |> Kernel.||("") 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/plug/verify_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.VerifyHeader do 2 | @moduledoc """ 3 | Use this plug to authenticate a token contained in the header. 4 | You should set the value of the Authorization header to: 5 | Authorization: 6 | 7 | ## Example 8 | plug ExOauth2Provider.Plug.VerifyHeader, otp_app: :my_app 9 | 10 | A "realm" can be specified when using the plug. 11 | Realms are like the name of the token and allow many tokens 12 | to be sent with a single request. 13 | 14 | plug ExOauth2Provider.Plug.VerifyHeader, otp_app: :my_app, realm: "Bearer" 15 | 16 | When a realm is not specified, the first authorization header 17 | found is used, and assumed to be a raw token 18 | 19 | #### example 20 | plug ExOauth2Provider.Plug.VerifyHeader, otp_app: :my_app 21 | 22 | # will take the first auth header 23 | # Authorization: 24 | """ 25 | 26 | alias Plug.Conn 27 | alias ExOauth2Provider.Plug 28 | 29 | @doc false 30 | @spec init(keyword()) :: keyword() 31 | def init(opts \\ []) do 32 | opts 33 | |> Keyword.get(:realm) 34 | |> maybe_set_realm_option(opts) 35 | end 36 | 37 | defp maybe_set_realm_option(nil, opts), do: opts 38 | defp maybe_set_realm_option(realm, opts) do 39 | realm = Regex.escape(realm) 40 | {:ok, realm_regex} = Regex.compile("#{realm}\:?\s+(.*)$", "i") 41 | 42 | Keyword.put(opts, :realm_regex, realm_regex) 43 | end 44 | 45 | @doc false 46 | @spec call(Conn.t(), keyword()) :: Conn.t() 47 | def call(conn, opts) do 48 | key = Keyword.get(opts, :key, :default) 49 | config = Keyword.take(opts, [:otp_app]) 50 | 51 | conn 52 | |> fetch_token(opts) 53 | |> verify_token(conn, key, config) 54 | end 55 | 56 | defp fetch_token(conn, opts) do 57 | auth_header = Conn.get_req_header(conn, "authorization") 58 | 59 | opts 60 | |> Keyword.get(:realm_regex) 61 | |> do_fetch_token(auth_header) 62 | end 63 | 64 | defp do_fetch_token(_realm_regex, []), do: nil 65 | defp do_fetch_token(nil, [token | _tail]), do: String.trim(token) 66 | defp do_fetch_token(realm_regex, [token | tail]) do 67 | trimmed_token = String.trim(token) 68 | 69 | case Regex.run(realm_regex, trimmed_token) do 70 | [_, match] -> String.trim(match) 71 | _ -> do_fetch_token(realm_regex, tail) 72 | end 73 | end 74 | 75 | defp verify_token(nil, conn, _, _config), do: conn 76 | defp verify_token("", conn, _, _config), do: conn 77 | defp verify_token(token, conn, key, config) do 78 | access_token = ExOauth2Provider.authenticate_token(token, config) 79 | 80 | Plug.set_current_access_token(conn, access_token, key) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/redirect_uri.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.RedirectURI do 2 | @moduledoc """ 3 | Functions for dealing with redirect uri. 4 | """ 5 | alias ExOauth2Provider.{Config, Utils} 6 | 7 | @doc """ 8 | Validates if a url can be used as a redirect_uri. 9 | 10 | Validates according to [RFC 6749 3.1.2](https://tools.ietf.org/html/rfc6749#section-3.1.2) 11 | and [RFC 8252 7.1](https://tools.ietf.org/html/rfc8252#section-7.1). The validation is 12 | skipped if the redirect uri is the same as the `:native_redirect_uri` configuration 13 | setting. 14 | """ 15 | @spec validate(binary() | nil, keyword()) :: {:ok, binary()} | {:error, binary()} 16 | def validate(nil, config), do: validate("", config) 17 | def validate(url, config) when is_binary(url) do 18 | url 19 | |> String.trim() 20 | |> case do 21 | "" -> 22 | {:error, "Redirect URI cannot be blank"} 23 | 24 | url -> 25 | case native_redirect_uri?(url, config) do 26 | true -> {:ok, url} 27 | false -> do_validate(url, URI.parse(url), config) 28 | end 29 | end 30 | end 31 | 32 | defp do_validate(_url, %{fragment: fragment}, _config) when not is_nil(fragment), 33 | do: {:error, "Redirect URI cannot contain fragments"} 34 | defp do_validate(url, %{scheme: scheme} = uri, config) when not is_nil(scheme) do 35 | case invalid_ssl_uri?(uri, config) do 36 | true -> {:error, "Redirect URI must be an HTTPS/SSL URI"} 37 | false -> {:ok, url} 38 | end 39 | end 40 | defp do_validate(_url, _uri, _config), 41 | do: {:error, "Redirect URI must be an absolute URI"} 42 | 43 | defp invalid_ssl_uri?(%{scheme: "http"}, config), do: Config.force_ssl_in_redirect_uri?(config) 44 | defp invalid_ssl_uri?(_uri, _config), do: false 45 | 46 | @doc false 47 | @deprecated "Use `matches?/3` instead" 48 | def matches?(uri, client_uri), do: matches?(uri, client_uri, []) 49 | 50 | @doc """ 51 | Check if uri matches client uri 52 | """ 53 | @spec matches?(binary(), binary(), keyword()) :: boolean() 54 | def matches?(uri, client_uri, config) when is_binary(uri) and is_binary(client_uri) do 55 | matches?(URI.parse(uri), URI.parse(client_uri), config) 56 | end 57 | @spec matches?(URI.t(), URI.t(), keyword()) :: boolean() 58 | def matches?(%URI{} = uri, %URI{} = client_uri, config) do 59 | case Config.redirect_uri_match_fun(config) do 60 | nil -> client_uri == %{uri | query: nil} 61 | fun -> fun.(uri, client_uri, config) 62 | end 63 | end 64 | 65 | @doc """ 66 | Check if a url matches a client redirect_uri 67 | """ 68 | @spec valid_for_authorization?(binary(), binary(), keyword()) :: boolean() 69 | def valid_for_authorization?(url, client_url, config) do 70 | url 71 | |> validate(config) 72 | |> do_valid_for_authorization?(client_url, config) 73 | end 74 | 75 | defp do_valid_for_authorization?({:error, _error}, _client_url, _config), do: false 76 | defp do_valid_for_authorization?({:ok, url}, client_url, config) do 77 | client_url 78 | |> String.split() 79 | |> Enum.any?(&matches?(url, &1, config)) 80 | end 81 | 82 | @doc """ 83 | Check if a url is native 84 | """ 85 | @spec native_redirect_uri?(binary(), keyword()) :: boolean() 86 | def native_redirect_uri?(url, config) do 87 | Config.native_redirect_uri(config) == url 88 | end 89 | 90 | @doc """ 91 | Adds query parameters to uri 92 | """ 93 | @spec uri_with_query(binary() | URI.t(), map()) :: binary() 94 | def uri_with_query(uri, query) when is_binary(uri) do 95 | uri 96 | |> URI.parse() 97 | |> uri_with_query(query) 98 | end 99 | def uri_with_query(%URI{} = uri, query) do 100 | query = add_query_params(uri.query || "", query) 101 | 102 | uri 103 | |> Map.put(:query, query) 104 | |> to_string() 105 | end 106 | 107 | defp add_query_params(query, attrs) do 108 | query 109 | |> URI.decode_query(attrs) 110 | |> Utils.remove_empty_values() 111 | |> URI.encode_query() 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Schema do 2 | @moduledoc """ 3 | This module will permit dynamic App.Schema load. 4 | """ 5 | 6 | alias ExOauth2Provider.Config 7 | 8 | defmacro __using__(config \\ []) do 9 | quote do 10 | @config unquote(config) 11 | end 12 | end 13 | 14 | @doc false 15 | defmacro fields(module) do 16 | quote do 17 | Enum.each(unquote(module).attrs(), fn 18 | {name, type} -> 19 | field(name, type) 20 | 21 | {name, type, field_options} -> 22 | field(name, type, field_options) 23 | 24 | {name, type, field_options, _migration_options} -> 25 | field(name, type, field_options) 26 | end) 27 | 28 | unquote(module).assocs() 29 | |> unquote(__MODULE__).__assocs_with_queryable__(@config) 30 | |> unquote(__MODULE__).__filter_new_assocs__(@ecto_assocs) 31 | |> Enum.each(fn 32 | {:belongs_to, name, queryable} -> 33 | belongs_to(name, queryable) 34 | 35 | {:belongs_to, name, queryable, options} -> 36 | belongs_to(name, queryable, options) 37 | 38 | {:has_many, name, queryable} -> 39 | has_many(name, queryable) 40 | 41 | {:has_many, name, queryable, options} -> 42 | has_many(name, queryable, options) 43 | end) 44 | end 45 | end 46 | 47 | @doc false 48 | def __assocs_with_queryable__(assocs, config) do 49 | Enum.map(assocs, fn 50 | {:belongs_to, name, table} -> {:belongs_to, name, table_to_queryable(config, table)} 51 | {:belongs_to, name, table, defaults} -> {:belongs_to, name, table_to_queryable(config, table), defaults} 52 | {:has_many, name, table} -> {:has_many, name, table_to_queryable(config, table)} 53 | {:has_many, name, table, defaults} -> {:has_many, name, table_to_queryable(config, table), defaults} 54 | end) 55 | end 56 | 57 | defp table_to_queryable(config, :access_grants), do: Config.access_grant(config) 58 | defp table_to_queryable(config, :access_tokens), do: Config.access_token(config) 59 | defp table_to_queryable(config, :applications), do: Config.application(config) 60 | defp table_to_queryable(config, :users), do: Config.resource_owner(config) 61 | 62 | @doc false 63 | def __filter_new_assocs__(assocs, existing_assocs) do 64 | Enum.reject(assocs, fn assoc -> 65 | Enum.any?(existing_assocs, &assocs_match?(elem(assoc, 0), elem(assoc, 1), &1)) 66 | end) 67 | end 68 | 69 | defp assocs_match?(:has_many, name, {name, %Ecto.Association.Has{cardinality: :many}}), do: true 70 | defp assocs_match?(:belongs_to, name, {name, %Ecto.Association.BelongsTo{}}), do: true 71 | defp assocs_match?(_type, _name, _existing_assoc), do: false 72 | 73 | 74 | @doc false 75 | def __timestamp_for__(struct, column) do 76 | type = struct.__schema__(:type, column) 77 | 78 | __timestamp__(type) 79 | end 80 | 81 | @doc false 82 | def __timestamp__(:naive_datetime) do 83 | %{NaiveDateTime.utc_now() | microsecond: {0, 0}} 84 | end 85 | def __timestamp__(:naive_datetime_usec) do 86 | NaiveDateTime.utc_now() 87 | end 88 | def __timestamp__(:utc_datetime) do 89 | DateTime.from_unix!(System.system_time(:second), :second) 90 | end 91 | def __timestamp__(:utc_datetime_usec) do 92 | DateTime.from_unix!(System.system_time(:microsecond), :microsecond) 93 | end 94 | def __timestamp__(type) do 95 | type.from_unix!(System.system_time(:microsecond), :microsecond) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/scopes.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Scopes do 2 | @moduledoc """ 3 | Functions for dealing with scopes. 4 | """ 5 | 6 | alias ExOauth2Provider.Config 7 | 8 | @doc """ 9 | Check if required scopes exists in the scopes list 10 | """ 11 | @spec all?([binary()], [binary()]) :: boolean() 12 | def all?(scopes, required_scopes) do 13 | (required_scopes -- scopes) == [] 14 | end 15 | 16 | @doc """ 17 | Check if two lists of scopes are equal 18 | """ 19 | @spec equal?([binary()], [binary()]) :: boolean() 20 | def equal?(scopes, other_scopes) do 21 | Enum.sort(scopes) == Enum.sort(other_scopes) 22 | end 23 | 24 | @doc """ 25 | Filter defaults scopes from scopes list 26 | """ 27 | @spec filter_default_scopes([binary()], keyword()) :: [binary()] 28 | def filter_default_scopes(scopes, config) do 29 | default_server_scopes = Config.default_scopes(config) 30 | 31 | Enum.filter(scopes, &Enum.member?(default_server_scopes, &1)) 32 | end 33 | 34 | @doc """ 35 | Will default to server scopes if no scopes supplied 36 | """ 37 | @spec default_to_server_scopes([binary()], keyword()) :: [binary()] 38 | def default_to_server_scopes([], config), do: Config.server_scopes(config) 39 | def default_to_server_scopes(server_scopes, _config), do: server_scopes 40 | 41 | @doc """ 42 | Fetch scopes from an access token 43 | """ 44 | @spec from_access_token(map()) :: [binary()] 45 | def from_access_token(access_token), do: to_list(access_token.scopes) 46 | 47 | @doc """ 48 | Convert scopes string to list 49 | """ 50 | @spec to_list(binary()) :: [binary()] 51 | def to_list(nil), do: [] 52 | def to_list(str), do: String.split(str) 53 | 54 | @doc """ 55 | Convert scopes list to string 56 | """ 57 | @spec to_string(list()) :: binary() 58 | def to_string(scopes), do: Enum.join(scopes, " ") 59 | end 60 | -------------------------------------------------------------------------------- /lib/ex_oauth2_provider/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Utils do 2 | @moduledoc false 3 | 4 | @doc false 5 | @spec remove_empty_values(map()) :: map() 6 | def remove_empty_values(map) when is_map(map) do 7 | map 8 | |> Enum.filter(fn {_, v} -> v != nil && v != "" end) 9 | |> Enum.into(%{}) 10 | end 11 | 12 | @doc false 13 | @spec generate_token(keyword()) :: binary() 14 | def generate_token(opts \\ []) do 15 | opts 16 | |> Keyword.get(:size, 32) 17 | |> :crypto.strong_rand_bytes() 18 | |> Base.encode16(case: :lower) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/mix/ex_oauth2_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.ExOauth2Provider do 2 | @moduledoc """ 3 | Utilities module for mix tasks. 4 | """ 5 | alias Mix.Project 6 | 7 | @spec otp_app() :: atom() 8 | def otp_app(), do: Keyword.fetch!(Mix.Project.config(), :app) 9 | 10 | @doc """ 11 | Raises an exception if the project is an umbrella app. 12 | """ 13 | @spec no_umbrella!(binary()) :: :ok | no_return 14 | def no_umbrella!(task) do 15 | if Project.umbrella?() do 16 | Mix.raise("mix #{task} can only be run inside an application directory") 17 | end 18 | 19 | :ok 20 | end 21 | 22 | @doc """ 23 | Parses argument options into a map. 24 | """ 25 | @spec parse_options(OptionParser.argv(), Keyword.t(), Keyword.t()) :: {map(), OptionParser.argv(), OptionParser.errors()} 26 | def parse_options(args, switches, default_opts) do 27 | {opts, parsed, invalid} = OptionParser.parse(args, switches: switches) 28 | default_opts = to_map(default_opts) 29 | opts = to_map(opts) 30 | config = 31 | default_opts 32 | |> Map.merge(opts) 33 | |> context_app_to_atom() 34 | 35 | {config, parsed, invalid} 36 | end 37 | 38 | defp to_map(keyword) do 39 | Enum.reduce(keyword, %{}, fn {key, value}, map -> 40 | case Map.get(map, key) do 41 | nil -> 42 | Map.put(map, key, value) 43 | 44 | existing_value -> 45 | value = List.wrap(existing_value) ++ [value] 46 | Map.put(map, key, value) 47 | end 48 | end) 49 | end 50 | 51 | defp context_app_to_atom(%{context_app: context_app} = config), 52 | do: Map.put(config, :context_app, String.to_atom(context_app)) 53 | defp context_app_to_atom(config), 54 | do: config 55 | end 56 | -------------------------------------------------------------------------------- /lib/mix/ex_oauth2_provider/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.ExOauth2Provider.Config do 2 | @moduledoc false 3 | 4 | @template """ 5 | config :<%= app %>, <%= inspect key %><%= for {key, value} <- opts do %>, 6 | <%= key %>: <%= value %><% end %> 7 | """ 8 | 9 | @spec gen(binary() | atom(), keyword()) :: binary() 10 | def gen(context_app, opts), do: EEx.eval_string(@template, app: context_app, key: ExOauth2Provider, opts: opts) 11 | end 12 | -------------------------------------------------------------------------------- /lib/mix/ex_oauth2_provider/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.ExOauth2Provider.Migration do 2 | @moduledoc """ 3 | Utilities module for ecto migrations in mix tasks. 4 | """ 5 | alias Mix.Generator 6 | 7 | @doc """ 8 | Creates a migration file for a repo. 9 | """ 10 | @spec create_migration_file(atom(), binary(), binary()) :: any() 11 | def create_migration_file(repo, name, content) do 12 | base_name = "#{Macro.underscore(name)}.exs" 13 | path = 14 | repo 15 | |> Mix.EctoSQL.source_repo_priv() 16 | |> Path.join("migrations") 17 | |> maybe_create_directory() 18 | timestamp = timestamp(path) 19 | 20 | path 21 | |> ensure_unique(base_name, name) 22 | |> Path.join("#{timestamp}_#{base_name}") 23 | |> Generator.create_file(content) 24 | end 25 | 26 | defp maybe_create_directory(path) do 27 | Generator.create_directory(path) 28 | 29 | path 30 | end 31 | 32 | defp ensure_unique(path, base_name, name) do 33 | path 34 | |> Path.join("*_#{base_name}") 35 | |> Path.wildcard() 36 | |> case do 37 | [] -> path 38 | _ -> Mix.raise("migration can't be created, there is already a migration file with name #{name}.") 39 | end 40 | end 41 | 42 | defp timestamp(path, seconds \\ 0) do 43 | timestamp = gen_timestamp(seconds) 44 | 45 | path 46 | |> Path.join("#{timestamp}_*.exs") 47 | |> Path.wildcard() 48 | |> case do 49 | [] -> timestamp 50 | _ -> timestamp(path, seconds + 1) 51 | end 52 | end 53 | 54 | defp gen_timestamp(seconds) do 55 | %{year: y, month: m, day: d, hour: hh, minute: mm, second: ss} = 56 | DateTime.utc_now() 57 | |> DateTime.to_unix() 58 | |> Kernel.+(seconds) 59 | |> DateTime.from_unix!() 60 | 61 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 62 | end 63 | 64 | defp pad(i) when i < 10, do: << ?0, ?0 + i >> 65 | defp pad(i), do: to_string(i) 66 | 67 | @template """ 68 | defmodule <%= inspect migration.repo %>.Migrations.<%= migration.name %> do 69 | use Ecto.Migration 70 | 71 | def change do 72 | <%= for schema <- migration.schemas do %> 73 | create table(:<%= schema.table %><%= if schema.binary_id do %>, primary_key: false<% end %>) do 74 | <%= if schema.binary_id do %> add :id, :binary_id, primary_key: true 75 | <% end %><%= for {k, v} <- schema.attrs do %> add <%= inspect k %>, <%= inspect v %><%= schema.defaults[k] %> 76 | <% end %><%= for {_, i, _, s} <- schema.assocs do %> add <%= if(String.ends_with?(inspect(i), "_id"), do: inspect(i), else: inspect(i) <> "_id") %>, references(<%= inspect(s) %>, on_delete: :nothing<%= if schema.binary_id do %>, type: :binary_id<% end %>) 77 | <% end %> 78 | timestamps() 79 | end 80 | <%= for index <- schema.indexes do %> 81 | <%= index %><% end %> 82 | <% end %> 83 | end 84 | end 85 | """ 86 | 87 | alias ExOauth2Provider.{AccessGrants.AccessGrant, AccessTokens.AccessToken, Applications.Application} 88 | 89 | @schemas [{"applications", Application}, {"access_grants", AccessGrant}, {"access_tokens", AccessToken}] 90 | 91 | @spec gen(binary(), binary(), map()) :: binary() 92 | def gen(name, namespace, %{repo: repo} = config) do 93 | schemas = 94 | for {table, module} <- @schemas, 95 | do: schema(module, table, namespace, config) 96 | 97 | EEx.eval_string(@template, migration: %{repo: repo, name: name, schemas: schemas}) 98 | end 99 | 100 | defp schema(module, table, namespace, %{binary_id: binary_id}) do 101 | attrs = 102 | module.attrs() 103 | |> Kernel.++(attrs_from_assocs(module.assocs(), namespace)) 104 | |> migration_attrs() 105 | 106 | defaults = defaults(attrs) 107 | {assocs, attrs} = partition_attrs(attrs) 108 | table = "#{namespace}_#{table}" 109 | indexes = migration_indexes(module.indexes(), table) 110 | 111 | %{ 112 | table: table, 113 | binary_id: binary_id, 114 | attrs: attrs, 115 | defaults: defaults, 116 | assocs: assocs, 117 | indexes: indexes 118 | } 119 | end 120 | 121 | defp attrs_from_assocs(assocs, namespace) do 122 | assocs 123 | |> Enum.map(&attr_from_assoc(&1, namespace)) 124 | |> Enum.reject(&is_nil/1) 125 | end 126 | 127 | defp attr_from_assoc({:belongs_to, name, :users}, _namespace) do 128 | {String.to_atom("#{name}_id"), {:references, :users}} 129 | end 130 | defp attr_from_assoc({:belongs_to, name, table}, namespace) do 131 | {String.to_atom("#{name}_id"), {:references, String.to_atom("#{namespace}_#{table}")}} 132 | end 133 | defp attr_from_assoc({:belongs_to, name, table, _defaults}, namespace), do: attr_from_assoc({:belongs_to, name, table}, namespace) 134 | defp attr_from_assoc(_assoc, _opts), do: nil 135 | 136 | defp migration_attrs(attrs) do 137 | Enum.map(attrs, &to_migration_attr/1) 138 | end 139 | 140 | defp to_migration_attr({name, type}) do 141 | {name, type, ""} 142 | end 143 | defp to_migration_attr({name, type, field_options}) do 144 | to_migration_attr({name, type, field_options, []}) 145 | end 146 | defp to_migration_attr({name, type, field_options, migration_options}) do 147 | field_options 148 | |> Keyword.get(:default) 149 | |> case do 150 | nil -> migration_options ++ [] 151 | default -> migration_options ++ [default: default] 152 | end 153 | |> case do 154 | [] -> 155 | to_migration_attr({name, type}) 156 | 157 | options -> 158 | options = Enum.map_join(options, ", ", fn {k, v} -> "#{k}: #{inspect v}" end) 159 | 160 | {name, type, ", #{options}"} 161 | end 162 | end 163 | 164 | defp defaults(attrs) do 165 | Enum.map(attrs, fn {key, _value, defaults} -> 166 | {key, defaults} 167 | end) 168 | end 169 | 170 | defp partition_attrs(attrs) do 171 | {assocs, attrs} = 172 | Enum.split_with(attrs, fn 173 | {_, {:references, _}, _} -> true 174 | _ -> false 175 | end) 176 | 177 | attrs = Enum.map(attrs, fn {key_id, type, _defaults} -> {key_id, type} end) 178 | assocs = 179 | Enum.map(assocs, fn {key_id, {:references, source}, _} -> 180 | key = String.replace(Atom.to_string(key_id), "_id", "") 181 | {String.to_atom(key), key_id, nil, source} 182 | end) 183 | 184 | {assocs, attrs} 185 | end 186 | 187 | defp migration_indexes(indexes, table) do 188 | Enum.map(indexes, &to_migration_index(table, &1)) 189 | end 190 | 191 | defp to_migration_index(table, {key_or_keys, true}), 192 | do: "create unique_index(:#{table}, #{inspect(List.wrap(key_or_keys))})" 193 | end 194 | -------------------------------------------------------------------------------- /lib/mix/ex_oauth2_provider/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.ExOauth2Provider.Schema do 2 | @moduledoc false 3 | 4 | alias Mix.Generator 5 | alias ExOauth2Provider.Config 6 | 7 | @template """ 8 | defmodule <%= inspect schema.module %> do 9 | use Ecto.Schema 10 | use <%= inspect schema.macro %>, otp_app: :<%= otp_app %> 11 | <%= if schema.binary_id do %> 12 | @primary_key {:id, :binary_id, autogenerate: true} 13 | @foreign_key_type :binary_id<% end %> 14 | schema <%= inspect schema.table %> do 15 | <%= schema.macro_fields %>() 16 | 17 | timestamps() 18 | end 19 | end 20 | """ 21 | 22 | alias ExOauth2Provider.{AccessGrants.AccessGrant, AccessTokens.AccessToken, Applications.Application} 23 | 24 | @schemas [{"application", Application}, {"access_grant", AccessGrant}, {"access_token", AccessToken}] 25 | 26 | @spec create_schema_files(atom(), binary(), keyword()) :: any() 27 | def create_schema_files(context_app, namespace, opts) do 28 | for {table, schema} <- @schemas do 29 | app_base = Config.app_base(context_app) 30 | table_name = "#{namespace}_#{table}s" 31 | context = Macro.camelize(table_name) 32 | module = Macro.camelize("#{namespace}_#{table}") 33 | file = "#{Macro.underscore(module)}.ex" 34 | module = Module.concat([app_base, context, module]) 35 | binary_id = Keyword.get(opts, :binary_id, false) 36 | macro = schema 37 | macro_fields = "#{table}_fields" 38 | content = EEx.eval_string(@template, schema: %{module: module, table: table_name, binary_id: binary_id, macro: macro, macro_fields: macro_fields}, otp_app: context_app) 39 | dir = "lib/#{context_app}/#{Macro.underscore(context)}/" 40 | 41 | File.mkdir_p!(dir) 42 | 43 | dir 44 | |> Path.join(file) 45 | |> Generator.create_file(content) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/mix/tasks/ex_oauth2_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider do 2 | use Mix.Task 3 | 4 | @shortdoc "Prints ExOauth2Provider help information" 5 | 6 | @moduledoc """ 7 | Prints ExOauth2Provider tasks and their information. 8 | mix ex_oauth2_provider 9 | """ 10 | 11 | @doc false 12 | def run(args) do 13 | case args do 14 | [] -> general() 15 | _ -> Mix.raise("Invalid arguments, expected: mix ex_oauth2_provider") 16 | end 17 | end 18 | 19 | defp general do 20 | Application.ensure_all_started(:ex_oauth2_provider) 21 | Mix.shell.info "ExOauth2Provider v#{Application.spec(:ex_oauth2_provider, :vsn)}" 22 | Mix.shell.info Application.spec(:ex_oauth2_provider, :description) 23 | Mix.shell.info "\nAvailable tasks:\n" 24 | Mix.Tasks.Help.run(["--search", "ex_oauth2_provider."]) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix/tasks/ex_oauth2_provider.gen.migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.Gen.Migration do 2 | @shortdoc "Generates ExOauth2Provider migration file" 3 | 4 | @moduledoc """ 5 | Generates migration file. 6 | 7 | mix ex_oauth2_provider.gen.migrations -r MyApp.Repo 8 | 9 | mix ex_oauth2_provider.gen.migrations -r MyApp.Repo --namespace oauth2 10 | 11 | This generator will add the oauth2 migration file in `priv/repo/migrations`. 12 | 13 | The repository must be set under `:ecto_repos` in the current app 14 | configuration or given via the `-r` option. 15 | 16 | By default, the migration will be generated to the 17 | "priv/YOUR_REPO/migrations" directory of the current application but it 18 | can be configured to be any subdirectory of `priv` by specifying the 19 | `:priv` key under the repository configuration. 20 | 21 | ## Arguments 22 | 23 | * `-r`, `--repo` - the repo module 24 | * `--binary-id` - use binary id for primary keys 25 | * `--namespace` - namespace to prepend table and schema module name 26 | """ 27 | use Mix.Task 28 | 29 | alias Mix.{Ecto, ExOauth2Provider, ExOauth2Provider.Migration} 30 | 31 | @switches [binary_id: :boolean, namespace: :string] 32 | @default_opts [binary_id: false, namespace: "oauth"] 33 | @mix_task "ex_oauth2_provider.gen.migrations" 34 | 35 | @impl true 36 | def run(args) do 37 | ExOauth2Provider.no_umbrella!(@mix_task) 38 | 39 | args 40 | |> ExOauth2Provider.parse_options(@switches, @default_opts) 41 | |> parse() 42 | |> create_migration_files(args) 43 | end 44 | 45 | defp parse({config, _parsed, _invalid}), do: config 46 | 47 | defp create_migration_files(config, args) do 48 | args 49 | |> Ecto.parse_repo() 50 | |> Enum.map(&ensure_repo(&1, args)) 51 | |> Enum.map(&Map.put(config, :repo, &1)) 52 | |> Enum.each(&create_migration_files/1) 53 | end 54 | 55 | defp create_migration_files(%{repo: repo, namespace: namespace} = config) do 56 | name = "Create#{Macro.camelize(namespace)}Tables" 57 | content = Migration.gen(name, namespace, config) 58 | 59 | Migration.create_migration_file(repo, name, content) 60 | end 61 | 62 | defp ensure_repo(repo, args) do 63 | Ecto.ensure_repo(repo, args ++ ~w(--no-deps-check)) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.Gen.Schemas do 2 | @shortdoc "Generates ExOauth2Provider schema files" 3 | 4 | @moduledoc """ 5 | Generates schema files. 6 | 7 | mix ex_oauth2_provider.gen.schemas 8 | 9 | mix ex_oauth2_provider.gen.schemas --binary-id --namespace oauth2 10 | 11 | ## Arguments 12 | 13 | * `--binary-id` - use binary id for primary keys 14 | * `--namespace` - namespace to prepend table and schema module name 15 | * `--context-app` - context app to use for path and module names 16 | """ 17 | use Mix.Task 18 | 19 | alias Mix.{ExOauth2Provider, ExOauth2Provider.Schema} 20 | 21 | @switches [binary_id: :boolean, context_app: :string, namespace: :string] 22 | @default_opts [binary_id: false, namespace: "oauth"] 23 | @mix_task "ex_oauth2_provider.gen.migrations" 24 | 25 | @impl true 26 | def run(args) do 27 | ExOauth2Provider.no_umbrella!(@mix_task) 28 | 29 | args 30 | |> ExOauth2Provider.parse_options(@switches, @default_opts) 31 | |> parse() 32 | |> create_schema_files() 33 | end 34 | 35 | defp parse({config, _parsed, _invalid}), do: config 36 | 37 | defp create_schema_files(%{binary_id: binary_id, namespace: namespace} = config) do 38 | context_app = Map.get(config, :context_app) || ExOauth2Provider.otp_app() 39 | 40 | Schema.create_schema_files(context_app, namespace, binary_id: binary_id) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/mix/tasks/ex_oauth2_provider.install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.Install do 2 | @shortdoc "Installs ExOauth2Provider" 3 | 4 | @moduledoc """ 5 | Generates migrations, schema module files, and updates config. 6 | 7 | mix ex_oauth2_provider.install 8 | 9 | mix ex_oauth2_provider.install --no-schemas 10 | 11 | ## Arguments 12 | 13 | * `--context-app` - context app to use for path and module names 14 | * `--no-migration` - don't create migration file 15 | * `--no-schemas` - don't create schema module files 16 | """ 17 | 18 | use Mix.Task 19 | 20 | alias ExOauth2Provider.Config, as: ProviderConfig 21 | alias Mix.{Ecto, ExOauth2Provider, ExOauth2Provider.Config} 22 | alias Mix.Tasks.ExOauth2Provider.Gen.{Migration, Schemas} 23 | 24 | @switches [context_app: :string, migration: :boolean, schemas: :boolean] 25 | @default_opts [migration: true, schemas: true] 26 | @mix_task "ex_oauth2_provider.install" 27 | 28 | @impl true 29 | def run(args) do 30 | ExOauth2Provider.no_umbrella!(@mix_task) 31 | 32 | args 33 | |> ExOauth2Provider.parse_options(@switches, @default_opts) 34 | |> parse() 35 | |> run_migration(args) 36 | |> run_schemas(args) 37 | |> print_config_instructions(args) 38 | end 39 | 40 | defp parse({config, _parsed, _invalid}), do: config 41 | 42 | defp run_migration(%{migration: true} = config, args) do 43 | Migration.run(args) 44 | 45 | config 46 | end 47 | defp run_migration(config, _args), do: config 48 | 49 | defp run_schemas(%{schemas: true} = config, args) do 50 | Schemas.run(args) 51 | 52 | config 53 | end 54 | defp run_schemas(config, _args), do: config 55 | 56 | defp print_config_instructions(config, args) do 57 | [repo | _repos] = Ecto.parse_repo(args) 58 | context_app = Map.get(config, :context_app) || ExOauth2Provider.otp_app() 59 | resource_owner = resource_owner(ProviderConfig.app_base(context_app)) 60 | 61 | content = Config.gen(context_app, repo: inspect(repo), resource_owner: resource_owner) 62 | 63 | Mix.shell.info( 64 | """ 65 | ExOauth2Provider has been installed! Please append the following to `config/config.ex`: 66 | 67 | #{content} 68 | """) 69 | 70 | config 71 | end 72 | 73 | defp resource_owner(base), do: inspect Module.concat([base, "Users", "User"]) 74 | end 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.5.7" 5 | 6 | def project do 7 | [ 8 | app: :ex_oauth2_provider, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | elixirc_paths: elixirc_paths(Mix.env), 12 | start_permanent: Mix.env == :prod, 13 | deps: deps(), 14 | 15 | # Hex 16 | description: "No brainer OAuth 2.0 provider", 17 | package: package(), 18 | 19 | # Docs 20 | name: "ExOauth2Provider", 21 | docs: docs() 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: extra_applications(Mix.env)] 27 | end 28 | 29 | defp extra_applications(:test), do: [:ecto, :logger] 30 | defp extra_applications(_), do: [:logger] 31 | 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | defp deps do 36 | [ 37 | {:ecto, "~> 3.10"}, 38 | {:plug, ">= 1.5.0 and < 2.0.0"}, 39 | {:jason, "~> 1.2"}, 40 | 41 | # Dev and test dependencies 42 | {:credo, "~> 1.5", only: [:dev, :test]}, 43 | 44 | {:ex_doc, "~> 0.25", only: :dev}, 45 | 46 | {:ecto_sql, "~> 3.10", only: :test}, 47 | {:plug_cowboy, "~> 2.0", only: :test}, 48 | {:postgrex, "~> 0.14", only: :test}] 49 | end 50 | 51 | defp package do 52 | [ 53 | maintainers: ["Dan Shultzer", "Benjamin Schultzer"], 54 | licenses: ["MIT"], 55 | links: %{github: "https://github.com/danschultzer/ex_oauth2_provider"}, 56 | files: ~w(lib LICENSE mix.exs README.md) 57 | ] 58 | end 59 | 60 | defp docs do 61 | [ 62 | source_ref: "v#{@version}", 63 | main: "ExOauth2Provider", 64 | canonical: "http://hexdocs.pm/ex_oauth2_provider", 65 | source_url: "https://github.com/danschultzer/ex_oauth2_provider", 66 | extras: ["README.md"] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 4 | "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, 5 | "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, 6 | "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [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", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, 7 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 8 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 9 | "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm", "762b999fd414fb41e297944228aa1de2cd4a3876a07f968c8b11d1e9a2190d07"}, 10 | "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, 11 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 13 | "ex_doc": {:hex, :ex_doc, "0.30.4", "e8395c8e3c007321abb30a334f9f7c0858d80949af298302daf77553468c0c39", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9a19f0c50ffaa02435668f5242f2b2a61d46b541ebf326884505dfd3dd7af5e4"}, 14 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 15 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 16 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 19 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 21 | "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm", "de9825f21c6fd6adfdeae8f9c80dcd88c1e58301f06bf13d659b7e606b88abe0"}, 22 | "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, 24 | "postgrex": {:hex, :postgrex, "0.17.2", "a3ec9e3239d9b33f1e5841565c4eb200055c52cc0757a22b63ca2d529bbe764c", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "80a918a9e9531d39f7bd70621422f3ebc93c01618c645f2d91306f50041ed90c"}, 25 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 26 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 27 | } 28 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/access_grants/access_grants_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.AccessGrantsTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.AccessGrants 5 | alias ExOauth2Provider.Test.Fixtures 6 | alias Dummy.OauthAccessGrants.OauthAccessGrant 7 | 8 | @valid_attrs %{expires_in: 600, redirect_uri: "https://example.org/endpoint"} 9 | 10 | setup do 11 | user = Fixtures.resource_owner() 12 | {:ok, %{user: user, application: Fixtures.application(resource_owner: user, scopes: "public read")}} 13 | end 14 | 15 | test "get_active_grant_for/3", %{user: user, application: application} do 16 | {:ok, grant} = AccessGrants.create_grant(user, application, @valid_attrs, otp_app: :ex_oauth2_provider) 17 | 18 | assert %OauthAccessGrant{id: id} = AccessGrants.get_active_grant_for(application, grant.token, otp_app: :ex_oauth2_provider) 19 | assert id == grant.id 20 | 21 | different_application = Fixtures.application(resource_owner: user, uid: "2") 22 | refute AccessGrants.get_active_grant_for(different_application, grant.token, otp_app: :ex_oauth2_provider) 23 | end 24 | 25 | describe "create_grant/4" do 26 | test "with valid attributes", %{user: user, application: application} do 27 | assert {:ok, %OauthAccessGrant{} = grant} = AccessGrants.create_grant(user, application, @valid_attrs, otp_app: :ex_oauth2_provider) 28 | assert grant.resource_owner == user 29 | assert grant.application == application 30 | assert grant.scopes == "public" 31 | end 32 | 33 | test "adds random token", %{user: user, application: application} do 34 | {:ok, grant} = AccessGrants.create_grant(user, application, @valid_attrs, otp_app: :ex_oauth2_provider) 35 | {:ok, grant2} = AccessGrants.create_grant(user, application, @valid_attrs, otp_app: :ex_oauth2_provider) 36 | assert grant.token != grant2.token 37 | end 38 | 39 | test "with missing expires_in", %{application: application, user: user} do 40 | attrs = Map.merge(@valid_attrs, %{expires_in: nil}) 41 | 42 | assert {:error, changeset} = AccessGrants.create_grant(user, application, attrs, otp_app: :ex_oauth2_provider) 43 | assert changeset.errors[:expires_in] == {"can't be blank", [validation: :required]} 44 | end 45 | 46 | test "with missing redirect_uri", %{application: application, user: user} do 47 | attrs = Map.merge(@valid_attrs, %{redirect_uri: nil}) 48 | 49 | assert {:error, changeset} = AccessGrants.create_grant(user, application, attrs, otp_app: :ex_oauth2_provider) 50 | assert changeset.errors[:redirect_uri] == {"can't be blank", [validation: :required]} 51 | end 52 | 53 | test "with invalid scopes", %{application: application, user: user} do 54 | attrs = Map.merge(@valid_attrs, %{scopes: "write"}) 55 | 56 | assert {:error, changeset} = AccessGrants.create_grant(user, application, attrs, otp_app: :ex_oauth2_provider) 57 | assert changeset.errors[:scopes] == {"not in permitted scopes list: \"public read\"", []} 58 | end 59 | end 60 | 61 | describe "create_grant/4 with no application scopes" do 62 | setup %{user: user, application: application} do 63 | application = Map.merge(application, %{scopes: ""}) 64 | %{user: user, application: application} 65 | end 66 | 67 | test "with invalid scopes", %{application: application, user: user} do 68 | attrs = Map.merge(@valid_attrs, %{scopes: "invalid"}) 69 | 70 | assert {:error, changeset} = AccessGrants.create_grant(user, application, attrs, otp_app: :ex_oauth2_provider) 71 | assert changeset.errors[:scopes] == {"not in permitted scopes list: [\"public\", \"read\", \"write\"]", []} 72 | end 73 | 74 | test "with valid attributes", %{application: application, user: user} do 75 | attrs = Map.merge(@valid_attrs, %{scopes: "write"}) 76 | 77 | assert {:ok, grant} = AccessGrants.create_grant(user, application, attrs, otp_app: :ex_oauth2_provider) 78 | assert grant.scopes == "write" 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/applications/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Applications.ApplicationTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.Applications.Application 5 | alias Dummy.OauthApplications.OauthApplication 6 | 7 | describe "changeset/2 with existing application" do 8 | setup do 9 | application = Ecto.put_meta(%OauthApplication{}, state: :loaded) 10 | 11 | {:ok, application: application} 12 | end 13 | 14 | test "validates", %{application: application} do 15 | changeset = Application.changeset(application, %{name: ""}) 16 | assert changeset.errors[:name] 17 | end 18 | 19 | test "validates uid", %{application: application} do 20 | changeset = Application.changeset(application, %{uid: ""}) 21 | assert changeset.errors[:uid] 22 | end 23 | 24 | test "validates secret", %{application: application} do 25 | changeset = Application.changeset(application, %{secret: nil}) 26 | assert changeset.errors[:secret] == {"can't be blank", []} 27 | 28 | changeset = Application.changeset(application, %{secret: ""}) 29 | assert is_nil(changeset.errors[:secret]) 30 | end 31 | 32 | test "requires valid redirect uri", %{application: application} do 33 | changeset = Application.changeset(application, %{redirect_uri: ""}) 34 | assert changeset.errors[:redirect_uri] 35 | end 36 | 37 | test "require valid redirect uri", %{application: application} do 38 | ["", 39 | "invalid", 40 | "https://example.com invalid", 41 | "https://example.com http://example.com"] 42 | |> Enum.each(fn(redirect_uri) -> 43 | changeset = Application.changeset(application, %{redirect_uri: redirect_uri}) 44 | assert changeset.errors[:redirect_uri] 45 | end) 46 | end 47 | 48 | test "doesn't require scopes", %{application: application} do 49 | changeset = Application.changeset(application, %{scopes: ""}) 50 | refute changeset.errors[:scopes] 51 | end 52 | end 53 | 54 | defmodule OverrideOwner do 55 | @moduledoc false 56 | 57 | use Ecto.Schema 58 | use ExOauth2Provider.Applications.Application, otp_app: :ex_oauth2_provider 59 | 60 | if System.get_env("UUID") do 61 | @primary_key {:id, :binary_id, autogenerate: true} 62 | @foreign_key_type :binary_id 63 | end 64 | 65 | schema "oauth_applications" do 66 | belongs_to :owner, __MODULE__ 67 | 68 | application_fields() 69 | timestamps() 70 | end 71 | end 72 | 73 | test "with overridden `:owner`" do 74 | assert %Ecto.Association.BelongsTo{owner: OverrideOwner} = OverrideOwner.__schema__(:association, :owner) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/applications/applications_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.ApplicationsTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.Test.Fixtures 5 | alias ExOauth2Provider.{AccessTokens, Applications} 6 | alias Dummy.{OauthApplications.OauthApplication, OauthAccessTokens.OauthAccessToken, Repo} 7 | 8 | @valid_attrs %{name: "Application", redirect_uri: "https://example.org/endpoint"} 9 | @invalid_attrs %{} 10 | 11 | setup do 12 | {:ok, %{user: Fixtures.resource_owner()}} 13 | end 14 | 15 | test "get_applications_for/2", %{user: user} do 16 | assert {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 17 | assert {:ok, _application} = Applications.create_application(Fixtures.resource_owner(), @valid_attrs, otp_app: :ex_oauth2_provider) 18 | 19 | assert [%OauthApplication{id: id}] = Applications.get_applications_for(user, otp_app: :ex_oauth2_provider) 20 | assert id == application.id 21 | end 22 | 23 | test "get_application!/2", %{user: user} do 24 | assert {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 25 | 26 | assert %OauthApplication{id: id} = Applications.get_application!(application.uid, otp_app: :ex_oauth2_provider) 27 | assert id == application.id 28 | end 29 | 30 | test "get_application/2", %{user: user} do 31 | assert {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 32 | 33 | assert %OauthApplication{id: id} = Applications.get_application(application.uid, otp_app: :ex_oauth2_provider) 34 | assert id == application.id 35 | end 36 | 37 | test "get_application_for!/2", %{user: user} do 38 | {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 39 | 40 | assert %OauthApplication{id: id} = Applications.get_application_for!(user, application.uid, otp_app: :ex_oauth2_provider) 41 | assert id == application.id 42 | 43 | assert_raise Ecto.NoResultsError, fn -> 44 | Applications.get_application_for!(Fixtures.resource_owner(), application.uid, otp_app: :ex_oauth2_provider) 45 | end 46 | end 47 | 48 | test "get_authorized_applications_for/2", %{user: user} do 49 | application = Fixtures.application() 50 | application2 = Fixtures.application(uid: "newapp") 51 | assert {:ok, token} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) 52 | assert {:ok, _token} = AccessTokens.create_token(user, %{application: application2}, otp_app: :ex_oauth2_provider) 53 | 54 | assert Applications.get_authorized_applications_for(user, otp_app: :ex_oauth2_provider) == [application, application2] 55 | assert Applications.get_authorized_applications_for(Fixtures.resource_owner(), otp_app: :ex_oauth2_provider) == [] 56 | 57 | AccessTokens.revoke(token, otp_app: :ex_oauth2_provider) 58 | assert Applications.get_authorized_applications_for(user, otp_app: :ex_oauth2_provider) == [application2] 59 | end 60 | 61 | describe "create_application/3" do 62 | test "with valid attributes", %{user: user} do 63 | assert {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 64 | assert application.name == @valid_attrs.name 65 | assert application.scopes == "public" 66 | end 67 | 68 | test "with invalid attributes", %{user: user} do 69 | assert {:error, changeset} = Applications.create_application(user, @invalid_attrs, otp_app: :ex_oauth2_provider) 70 | assert changeset.errors[:name] 71 | end 72 | 73 | test "with invalid scopes", %{user: user} do 74 | attrs = Map.merge(@valid_attrs, %{scopes: "invalid"}) 75 | 76 | assert {:error, %Ecto.Changeset{}} = Applications.create_application(user, attrs, otp_app: :ex_oauth2_provider) 77 | end 78 | 79 | test "with limited scopes", %{user: user} do 80 | attrs = Map.merge(@valid_attrs, %{scopes: "read write"}) 81 | 82 | assert {:ok, application} = Applications.create_application(user, attrs, otp_app: :ex_oauth2_provider) 83 | assert application.scopes == "read write" 84 | end 85 | 86 | test "adds random secret", %{user: user} do 87 | {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 88 | {:ok, application2} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 89 | 90 | assert application.secret != application2.secret 91 | end 92 | 93 | test "permits empty string secret", %{user: user} do 94 | attrs = Map.merge(@valid_attrs, %{secret: ""}) 95 | 96 | assert {:ok, application} = Applications.create_application(user, attrs, otp_app: :ex_oauth2_provider) 97 | assert application.secret == "" 98 | end 99 | 100 | test "adds random uid", %{user: user} do 101 | {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 102 | {:ok, application2} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 103 | assert application.uid != application2.uid 104 | end 105 | 106 | test "adds custom uid", %{user: user} do 107 | {:ok, application} = Applications.create_application(user, Map.merge(@valid_attrs, %{uid: "custom"}), otp_app: :ex_oauth2_provider) 108 | assert application.uid == "custom" 109 | end 110 | 111 | test "adds custom secret", %{user: user} do 112 | {:ok, application} = Applications.create_application(user, Map.merge(@valid_attrs, %{secret: "custom"}), otp_app: :ex_oauth2_provider) 113 | assert application.secret == "custom" 114 | end 115 | end 116 | 117 | test "update_application/3", %{user: user} do 118 | assert {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 119 | 120 | assert {:ok, application} = Applications.update_application(application, %{name: "Updated App"}, otp_app: :ex_oauth2_provider) 121 | assert application.name == "Updated App" 122 | end 123 | 124 | test "delete_application/2", %{user: user} do 125 | {:ok, application} = Applications.create_application(user, @valid_attrs, otp_app: :ex_oauth2_provider) 126 | 127 | assert {:ok, _appliction} = Applications.delete_application(application, otp_app: :ex_oauth2_provider) 128 | assert_raise Ecto.NoResultsError, fn -> 129 | Applications.get_application!(application.uid, otp_app: :ex_oauth2_provider) 130 | end 131 | end 132 | 133 | test "revoke_all_access_tokens_for/3", %{user: user} do 134 | application = Fixtures.application() 135 | {:ok, token} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) 136 | {:ok, token2} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) 137 | {:ok, token3} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) 138 | AccessTokens.revoke(token3) 139 | 140 | assert {:ok, objects} = Applications.revoke_all_access_tokens_for(application, user, otp_app: :ex_oauth2_provider) 141 | assert Enum.count(objects) == 2 142 | 143 | assert AccessTokens.is_revoked?(Repo.get!(OauthAccessToken, token.id)) 144 | assert AccessTokens.is_revoked?(Repo.get!(OauthAccessToken, token2.id)) 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.ConfigTest do 2 | use ExUnit.Case 3 | alias ExOauth2Provider.Config 4 | 5 | setup do 6 | config = Application.get_env(:ex_oauth2_provider, ExOauth2Provider) 7 | on_exit(fn -> 8 | Application.put_env(:ex_oauth2_provider, ExOauth2Provider, config) 9 | end) 10 | end 11 | 12 | test "repo/1" do 13 | assert Config.repo(otp_app: :my_app) == Dummy.Repo 14 | 15 | Application.delete_env(:ex_oauth2_provider, ExOauth2Provider) 16 | Application.put_env(:my_app, ExOauth2Provider, repo: Dummy.Repo) 17 | 18 | assert Config.repo(otp_app: :my_app) == Dummy.Repo 19 | 20 | Application.delete_env(:my_app, ExOauth2Provider) 21 | 22 | assert_raise RuntimeError, ~r/config :my_app, ExOauth2Provider/, fn -> 23 | Config.repo(otp_app: :my_app) 24 | end 25 | 26 | assert_raise RuntimeError, ~r/config :ex_oauth2_provider, ExOauth2Provider/, fn -> 27 | Config.repo([]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/keys_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.KeysTest do 2 | use ExUnit.Case 3 | alias ExOauth2Provider.Keys 4 | 5 | test "access_token/1" do 6 | assert Keys.access_token_key(:foo) == :ex_oauth2_provider_foo_access_token 7 | end 8 | 9 | test "base_key/1" do 10 | assert Keys.base_key(:foo) == :ex_oauth2_provider_foo 11 | end 12 | 13 | test "base_key/1 beginning with ex_oauth2_provider_" do 14 | assert Keys.base_key("ex_oauth2_provider_foo") == :ex_oauth2_provider_foo 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.AuthorizationTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.Authorization 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | 7 | @client_id "Jf5rM8hQBc" 8 | @client_secret "secret" 9 | @valid_request %{"client_id" => @client_id, "response_type" => "code", "scope" => "app:read app:write"} 10 | @invalid_request %{error: :invalid_request, 11 | error_description: "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." 12 | } 13 | @invalid_response_type %{error: :unsupported_response_type, 14 | error_description: "The authorization server does not support this response type." 15 | } 16 | 17 | setup do 18 | user = Fixtures.resource_owner() 19 | application = Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret) 20 | {:ok, %{resource_owner: user, application: application}} 21 | end 22 | 23 | test "#preauthorize/3 error when missing response_type", %{resource_owner: resource_owner} do 24 | params = Map.delete(@valid_request, "response_type") 25 | 26 | assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} 27 | end 28 | 29 | test "#preauthorize/3 redirect when missing response_type", %{resource_owner: resource_owner, application: application} do 30 | QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") 31 | 32 | params = @valid_request 33 | |> Map.delete("response_type") 34 | |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) 35 | 36 | assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:redirect, "https://example.com/path?error=invalid_request&error_description=The+request+is+missing+a+required+parameter%2C+includes+an+unsupported+parameter+value%2C+or+is+otherwise+malformed.¶m=1&state=40612"} 37 | end 38 | 39 | test "#preauthorize/3 error when unsupported response type", %{resource_owner: resource_owner} do 40 | params = Map.merge(@valid_request, %{"response_type" => "invalid"}) 41 | 42 | assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} 43 | end 44 | 45 | test "#preauthorize/3 redirect when unsupported response_type", %{resource_owner: resource_owner, application: application} do 46 | QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") 47 | 48 | params = @valid_request 49 | |> Map.merge(%{"response_type" => "invalid"}) 50 | |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) 51 | 52 | assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:redirect, "https://example.com/path?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+this+response+type.¶m=1&state=40612"} 53 | end 54 | 55 | test "#authorize/3 error when missing response_type", %{resource_owner: resource_owner} do 56 | params = Map.delete(@valid_request, "response_type") 57 | 58 | assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} 59 | end 60 | 61 | test "#authorize/3 rejects when unsupported response type", %{resource_owner: resource_owner} do 62 | params = Map.merge(@valid_request, %{"response_type" => "invalid"}) 63 | 64 | assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} 65 | end 66 | 67 | test "#deny/3 error when missing response_type", %{resource_owner: resource_owner} do 68 | params = Map.delete(@valid_request, "response_type") 69 | 70 | assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} 71 | end 72 | 73 | test "#deny/3 rejects when unsupported response type", %{resource_owner: resource_owner} do 74 | params = Map.merge(@valid_request, %{"response_type" => "invalid"}) 75 | 76 | assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token/strategy/authorization_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Strategy.AuthorizationCodeTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.{Config, Token, Token.AuthorizationCode, AccessGrants} 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | alias Dummy.OauthAccessTokens.OauthAccessToken 7 | 8 | @client_id "Jf5rM8hQBc" 9 | @client_secret "secret" 10 | @code "code" 11 | @redirect_uri "urn:ietf:wg:oauth:2.0:oob" 12 | @valid_request %{"client_id" => @client_id, 13 | "client_secret" => @client_secret, 14 | "code" => @code, 15 | "grant_type" => "authorization_code", 16 | "redirect_uri" => @redirect_uri} 17 | 18 | @invalid_client_error %{error: :invalid_client, 19 | error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 20 | } 21 | @invalid_grant %{error: :invalid_grant, 22 | error_description: "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client." 23 | } 24 | 25 | setup do 26 | resource_owner = Fixtures.resource_owner() 27 | application = Fixtures.application(uid: @client_id, secret: @client_secret) 28 | {:ok, %{resource_owner: resource_owner, application: application}} 29 | end 30 | 31 | setup %{resource_owner: resource_owner, application: application} do 32 | access_grant = Fixtures.access_grant(application, resource_owner, @code, @redirect_uri) 33 | {:ok, %{resource_owner: resource_owner, application: application, access_grant: access_grant}} 34 | end 35 | 36 | test "#grant/2 returns access token", %{resource_owner: resource_owner, application: application, access_grant: access_grant} do 37 | assert {:ok, body} = Token.grant(@valid_request, otp_app: :ex_oauth2_provider) 38 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 39 | 40 | assert body.access_token == access_token.token 41 | assert access_token.resource_owner_id == resource_owner.id 42 | assert access_token.application_id == application.id 43 | assert access_token.scopes == access_grant.scopes 44 | assert access_token.expires_in == Config.access_token_expires_in(otp_app: :ex_oauth2_provider) 45 | refute is_nil(access_token.refresh_token) 46 | end 47 | 48 | test "#grant/2 returns access token when client secret not required", %{resource_owner: resource_owner, application: application} do 49 | QueryHelpers.change!(application, secret: "") 50 | valid_request_no_client_secret = Map.drop(@valid_request, ["client_secret"]) 51 | 52 | assert {:ok, body} = Token.grant(valid_request_no_client_secret, otp_app: :ex_oauth2_provider) 53 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 54 | 55 | assert body.access_token == access_token.token 56 | assert access_token.resource_owner_id == resource_owner.id 57 | assert access_token.application_id == application.id 58 | end 59 | 60 | test "#grant/2 returns access token with custom response handler" do 61 | assert {:ok, body} = AuthorizationCode.grant(@valid_request, otp_app: :ex_oauth2_provider, access_token_response_body_handler: {__MODULE__, :access_token_response_body_handler}) 62 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 63 | 64 | assert body.custom_attr == access_token.inserted_at 65 | end 66 | 67 | test "#grant/2 doesn't set refresh_token when ExOauth2Provider.Config.use_refresh_token? == false" do 68 | assert {:ok, body} = AuthorizationCode.grant(@valid_request, otp_app: :ex_oauth2_provider, use_refresh_token: false) 69 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 70 | 71 | assert body.access_token == access_token.token 72 | assert is_nil(access_token.refresh_token) 73 | end 74 | 75 | test "#grant/2 can't use grant twice" do 76 | assert {:ok, _body} = Token.grant(@valid_request, otp_app: :ex_oauth2_provider) 77 | assert Token.grant(@valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 78 | end 79 | 80 | test "#grant/2 doesn't duplicate access token", %{resource_owner: resource_owner, application: application} do 81 | assert {:ok, body} = Token.grant(@valid_request, otp_app: :ex_oauth2_provider) 82 | access_grant = Fixtures.access_grant(application, resource_owner, "new_code", @redirect_uri) 83 | valid_request = Map.merge(@valid_request, %{"code" => access_grant.token}) 84 | assert {:ok, body2} = Token.grant(valid_request, otp_app: :ex_oauth2_provider) 85 | 86 | assert body.access_token == body2.access_token 87 | end 88 | 89 | test "#grant/2 error when invalid client" do 90 | request_invalid_client = Map.merge(@valid_request, %{"client_id" => "invalid"}) 91 | 92 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 93 | end 94 | 95 | test "#grant/2 error when invalid secret" do 96 | request_invalid_client = Map.merge(@valid_request, %{"client_secret" => "invalid"}) 97 | 98 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 99 | end 100 | 101 | test "#grant/2 error when invalid grant" do 102 | request_invalid_grant = Map.merge(@valid_request, %{"code" => "invalid"}) 103 | 104 | assert Token.grant(request_invalid_grant, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 105 | end 106 | 107 | test "#grant/2 error when grant owned by another client", %{access_grant: access_grant} do 108 | new_application = Fixtures.application(uid: "new_app") 109 | QueryHelpers.change!(access_grant, application_id: new_application.id) 110 | 111 | assert Token.grant(@valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 112 | end 113 | 114 | test "#grant/2 error when revoked grant", %{access_grant: access_grant} do 115 | QueryHelpers.change!(access_grant, revoked_at: DateTime.utc_now()) 116 | 117 | assert Token.grant(@valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 118 | end 119 | 120 | test "#grant/2 error when grant expired", %{access_grant: access_grant} do 121 | inserted_at = QueryHelpers.timestamp(OauthAccessToken, :inserted_at, seconds: -access_grant.expires_in) 122 | QueryHelpers.change!(access_grant, inserted_at: inserted_at) 123 | 124 | assert Token.grant(@valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 125 | end 126 | 127 | test "#grant/2 error when grant revoked", %{access_grant: access_grant} do 128 | AccessGrants.revoke(access_grant, otp_app: :ex_oauth2_provider) 129 | 130 | assert Token.grant(@valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 131 | end 132 | 133 | test "#grant/2 error when invalid redirect_uri" do 134 | request_invalid_redirect_uri = Map.merge(@valid_request, %{"redirect_uri" => "invalid"}) 135 | 136 | assert Token.grant(request_invalid_redirect_uri, otp_app: :ex_oauth2_provider) == {:error, @invalid_grant, :unprocessable_entity} 137 | end 138 | 139 | def access_token_response_body_handler(body, access_token) do 140 | Map.merge(body, %{custom_attr: access_token.inserted_at}) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token/strategy/client_credentials_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Strategy.ClientCredentialsTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.{Config, Token} 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | alias Dummy.OauthAccessTokens.OauthAccessToken 7 | 8 | @client_id "Jf5rM8hQBc" 9 | @client_secret "secret" 10 | @valid_request %{"client_id" => @client_id, 11 | "client_secret" => @client_secret, 12 | "grant_type" => "client_credentials", 13 | "scope" => "app:read"} 14 | @invalid_client_error %{error: :invalid_client, 15 | error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 16 | } 17 | 18 | setup do 19 | application = Fixtures.application(uid: @client_id, secret: @client_secret, scopes: "app:read app:write") 20 | {:ok, %{application: application}} 21 | end 22 | 23 | test "#grant/2 error when invalid client" do 24 | request_invalid_client = Map.merge(@valid_request, %{"client_id" => "invalid"}) 25 | 26 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 27 | end 28 | 29 | test "#grant/2 error when invalid secret" do 30 | request_invalid_client = Map.merge(@valid_request, %{"client_secret" => "invalid"}) 31 | 32 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 33 | end 34 | 35 | test "#grant/2 returns access token", %{application: application} do 36 | assert {:ok, body} = Token.grant(@valid_request, otp_app: :ex_oauth2_provider) 37 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 38 | 39 | assert body.access_token == access_token.token 40 | assert is_nil(access_token.resource_owner_id) 41 | assert access_token.application_id == application.id 42 | assert access_token.scopes == "app:read" 43 | assert access_token.expires_in == Config.access_token_expires_in(otp_app: :ex_oauth2_provider) 44 | 45 | # MUST NOT have refresh token 46 | assert access_token.refresh_token == nil 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token/strategy/password_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Strategy.PasswordTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.{Config, Token, Token.Password} 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | alias Dummy.OauthAccessTokens.OauthAccessToken 7 | 8 | @client_id "Jf5rM8hQBc" 9 | @client_secret "secret" 10 | @username "testuser@example.com" 11 | @password "secret" 12 | @valid_request %{"client_id" => @client_id, 13 | "client_secret" => @client_secret, 14 | "grant_type" => "password", 15 | "username" => @username, 16 | "password" => @password} 17 | @invalid_client_error %{error: :invalid_client, 18 | error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 19 | } 20 | @invalid_request_error %{error: :invalid_request, 21 | error_description: "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." 22 | } 23 | @invalid_scope %{error: :invalid_scope, 24 | error_description: "The requested scope is invalid, unknown, or malformed." 25 | } 26 | 27 | setup do 28 | user = Fixtures.resource_owner(email: @username) 29 | application = Fixtures.application(uid: @client_id, secret: @client_secret, scopes: "app:read app:write") 30 | {:ok, %{user: user, application: application}} 31 | end 32 | 33 | test "#grant/2 error when invalid client" do 34 | request_invalid_client = Map.merge(@valid_request, %{"client_id" => "invalid"}) 35 | 36 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 37 | end 38 | 39 | test "#grant/2 error when invalid secret" do 40 | request_invalid_client = Map.merge(@valid_request, %{"client_secret" => "invalid"}) 41 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 42 | 43 | request_invalid_client = Map.delete(@valid_request, "client_secret") 44 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 45 | end 46 | 47 | test "#grant/2 error when missing required values" do 48 | Enum.each(["username", "password"], fn(k) -> 49 | params = Map.delete(@valid_request, k) 50 | assert Token.grant(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request_error, :bad_request} 51 | end) 52 | end 53 | 54 | test "#grant/2 error when invalid password" do 55 | params = Map.merge(@valid_request, %{"password" => "invalid"}) 56 | 57 | assert Token.grant(params, otp_app: :ex_oauth2_provider) == {:error, :unauthorized, :unauthorized} 58 | end 59 | 60 | test "#grant/1 error when invalid scope" do 61 | params = Map.merge(@valid_request, %{"scope" => "invalid"}) 62 | 63 | assert Token.grant(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} 64 | end 65 | 66 | test "#grant/1 error when no password auth set" do 67 | expected_error = %{error: :unsupported_grant_type, error_description: "The authorization grant type is not supported by the authorization server."} 68 | 69 | assert Password.grant(@valid_request, otp_app: :ex_oauth2_provider, password_auth: nil) == {:error, expected_error, :unprocessable_entity} 70 | end 71 | 72 | test "#grant/1 returns access token", %{user: user, application: application} do 73 | assert {:ok, body} = Token.grant(@valid_request, otp_app: :ex_oauth2_provider) 74 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 75 | 76 | assert body.access_token == access_token.token 77 | assert access_token.resource_owner_id == user.id 78 | assert access_token.application_id == application.id 79 | assert access_token.scopes == application.scopes 80 | assert access_token.expires_in == Config.access_token_expires_in(otp_app: :ex_oauth2_provider) 81 | refute is_nil(access_token.refresh_token) 82 | end 83 | 84 | test "#grant/1 returns access token when only client_id required", %{user: user, application: application} do 85 | QueryHelpers.change!(application, secret: "") 86 | 87 | params = Map.delete(@valid_request, "client_secret") 88 | 89 | assert {:ok, body} = Token.grant(params, otp_app: :ex_oauth2_provider) 90 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 91 | 92 | assert body.access_token == access_token.token 93 | assert access_token.resource_owner_id == user.id 94 | assert access_token.application_id == application.id 95 | end 96 | 97 | test "#grant/1 returns access token with custom response handler" do 98 | assert {:ok, body} = Password.grant(@valid_request, otp_app: :ex_oauth2_provider, access_token_response_body_handler: {__MODULE__, :access_token_response_body_handler}) 99 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 100 | 101 | assert body.custom_attr == access_token.inserted_at 102 | end 103 | 104 | test "#grant/1 doesn't set refresh_token when ExOauth2Provider.Config.use_refresh_token? == false" do 105 | assert {:ok, body} = Password.grant(@valid_request, otp_app: :ex_oauth2_provider, use_refresh_token: false) 106 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 107 | 108 | assert body.access_token == access_token.token 109 | assert is_nil(access_token.refresh_token) 110 | end 111 | 112 | test "#grant/1 returns access token with limited scope" do 113 | params = Map.merge(@valid_request, %{"scope" => "app:read"}) 114 | assert {:ok, _} = Token.grant(params, otp_app: :ex_oauth2_provider) 115 | access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) 116 | 117 | assert access_token.scopes == "app:read" 118 | end 119 | 120 | def access_token_response_body_handler(body, access_token) do 121 | Map.merge(body, %{custom_attr: access_token.inserted_at}) 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token/strategy/refresh_token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Strategy.RefreshTokenTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.{Config, AccessTokens, Token, Token.RefreshToken} 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | alias Dummy.{OauthAccessTokens.OauthAccessToken, Repo} 7 | 8 | @client_id "Jf5rM8hQBc" 9 | @client_secret "secret" 10 | @invalid_client_error %{error: :invalid_client, 11 | error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 12 | } 13 | @invalid_request_error %{error: :invalid_request, 14 | error_description: "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." 15 | } 16 | 17 | setup do 18 | user = Fixtures.resource_owner() 19 | application = Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret, scopes: "app:read app:write") 20 | access_token = Fixtures.access_token(resource_owner: user, application: application, use_refresh_token: true, scopes: "app:read") 21 | 22 | valid_request = %{"client_id" => @client_id, 23 | "client_secret" => @client_secret, 24 | "grant_type" => "refresh_token", 25 | "refresh_token" => access_token.refresh_token} 26 | {:ok, %{access_token: access_token, valid_request: valid_request}} 27 | end 28 | 29 | test "#grant/2 error when invalid client", %{valid_request: valid_request} do 30 | request_invalid_client = Map.merge(valid_request, %{"client_id" => "invalid"}) 31 | 32 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 33 | end 34 | 35 | test "#grant/2 error when invalid secret", %{valid_request: valid_request} do 36 | request_invalid_client = Map.merge(valid_request, %{"client_secret" => "invalid"}) 37 | 38 | assert Token.grant(request_invalid_client, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 39 | end 40 | 41 | test "#grant/2 error when missing token", %{valid_request: valid_request} do 42 | params = Map.delete(valid_request, "refresh_token") 43 | 44 | assert Token.grant(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request_error, :bad_request} 45 | end 46 | 47 | test "#grant/2 error when invalid token", %{valid_request: valid_request} do 48 | params = Map.merge(valid_request, %{"refresh_token" => "invalid"}) 49 | 50 | assert Token.grant(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request_error, :bad_request} 51 | end 52 | 53 | test "#grant/2 error when access token owned by another client", %{valid_request: valid_request, access_token: access_token} do 54 | new_application = Fixtures.application(uid: "new_app") 55 | QueryHelpers.change!(access_token, application_id: new_application.id) 56 | 57 | assert Token.grant(valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request_error, :bad_request} 58 | end 59 | 60 | test "#grant/2 error when access token has been revoked", %{valid_request: valid_request, access_token: access_token} do 61 | QueryHelpers.change!(access_token, revoked_at: DateTime.utc_now()) 62 | 63 | assert Token.grant(valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request_error, :bad_request} 64 | end 65 | 66 | test "#grant/2 returns access token", %{valid_request: valid_request, access_token: access_token} do 67 | assert {:ok, new_access_token} = Token.grant(valid_request, otp_app: :ex_oauth2_provider) 68 | 69 | access_token = Repo.get_by(OauthAccessToken, id: access_token.id) 70 | new_access_token = Repo.get_by(OauthAccessToken, token: new_access_token.access_token) 71 | 72 | refute new_access_token.token == access_token.token 73 | assert new_access_token.resource_owner_id == access_token.resource_owner_id 74 | assert new_access_token.application_id == access_token.application_id 75 | assert new_access_token.scopes == access_token.scopes 76 | assert new_access_token.expires_in == Config.access_token_expires_in(otp_app: :ex_oauth2_provider) 77 | assert new_access_token.previous_refresh_token == access_token.refresh_token 78 | assert AccessTokens.is_revoked?(access_token) 79 | end 80 | 81 | test "#grant/2 returns access token with custom response handler", %{valid_request: valid_request} do 82 | assert {:ok, body} = RefreshToken.grant(valid_request, otp_app: :ex_oauth2_provider, access_token_response_body_handler: {__MODULE__, :access_token_response_body_handler}) 83 | access_token = Repo.get_by(OauthAccessToken, token: body.access_token) 84 | assert body.custom_attr == access_token.inserted_at 85 | end 86 | 87 | test "#grant/2 when refresh_token_revoked_on_use? == false", %{valid_request: valid_request, access_token: access_token} do 88 | assert {:ok, new_access_token} = RefreshToken.grant(valid_request, otp_app: :ex_oauth2_provider, revoke_refresh_token_on_use: false) 89 | 90 | access_token = Repo.get_by(OauthAccessToken, id: access_token.id) 91 | new_access_token = Repo.get_by(OauthAccessToken, token: new_access_token.access_token) 92 | 93 | assert new_access_token.previous_refresh_token == "" 94 | refute AccessTokens.is_revoked?(access_token) 95 | end 96 | 97 | def access_token_response_body_handler(body, access_token) do 98 | Map.merge(body, %{custom_attr: access_token.inserted_at}) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token/strategy/revoke_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Token.Strategy.RevokeTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.{AccessTokens, Token} 5 | alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} 6 | alias Dummy.OauthAccessTokens.OauthAccessToken 7 | 8 | @client_id "Jf5rM8hQBc" 9 | @client_secret "secret" 10 | @invalid_client_error %{error: :invalid_client, 11 | error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." 12 | } 13 | 14 | setup do 15 | user = Fixtures.resource_owner() 16 | application = Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret, scopes: "app:read app:write") 17 | access_token = Fixtures.access_token(resource_owner: user, application: application, use_refresh_token: true, scopes: "app:read") 18 | 19 | valid_request = %{"client_id" => @client_id, 20 | "client_secret" => @client_secret, 21 | "token" => access_token.token} 22 | {:ok, %{access_token: access_token, valid_request: valid_request}} 23 | end 24 | 25 | test "#revoke/2 error when invalid client", %{valid_request: valid_request} do 26 | params = Map.merge(valid_request, %{"client_id" => "invalid"}) 27 | 28 | assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 29 | end 30 | 31 | test "#revoke/2 error when invalid secret", %{valid_request: valid_request} do 32 | params = Map.merge(valid_request, %{"client_secret" => "invalid"}) 33 | 34 | assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} 35 | end 36 | 37 | test "#revoke/2 when missing token", %{valid_request: valid_request} do 38 | params = Map.delete(valid_request, "token") 39 | 40 | assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:ok, %{}} 41 | refute AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) 42 | end 43 | 44 | test "#revoke/2 when invalid token", %{valid_request: valid_request} do 45 | params = Map.merge(valid_request, %{"token" => "invalid"}) 46 | 47 | assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:ok, %{}} 48 | refute AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) 49 | end 50 | 51 | test "#revoke/2 when access token owned by another client", %{valid_request: valid_request, access_token: access_token} do 52 | new_application = Fixtures.application(uid: "new_app", secret: "new") 53 | QueryHelpers.change!(access_token, application_id: new_application.id) 54 | 55 | assert Token.revoke(valid_request, otp_app: :ex_oauth2_provider) == {:ok, %{}} 56 | refute AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) 57 | end 58 | 59 | test "#revoke/2 when access token not owned by a client", %{access_token: access_token} do 60 | QueryHelpers.change!(access_token, application_id: nil) 61 | 62 | params = %{"token" => access_token.token} 63 | 64 | assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:ok, %{}} 65 | assert AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) 66 | end 67 | 68 | test "#revoke/2", %{valid_request: valid_request} do 69 | assert Token.revoke(valid_request, otp_app: :ex_oauth2_provider) == {:ok, %{}} 70 | assert AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/oauth2/token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.TokenTest do 2 | use ExOauth2Provider.TestCase 3 | 4 | alias ExOauth2Provider.Token 5 | alias ExOauth2Provider.Test.Fixtures 6 | 7 | @client_id "Jf5rM8hQBc" 8 | @client_secret "secret" 9 | 10 | setup do 11 | application = Fixtures.application() 12 | {:ok, %{application: application}} 13 | end 14 | 15 | test "#grant/2 error when invalid grant_type" do 16 | request_invalid_grant_type = Map.merge(%{"client_id" => @client_id, 17 | "client_secret" => @client_secret, 18 | "grant_type" => "client_credentials"}, 19 | %{"grant_type" => "invalid"}) 20 | expected_error = %{error: :unsupported_grant_type, 21 | error_description: "The authorization grant type is not supported by the authorization server."} 22 | 23 | assert Token.grant(request_invalid_grant_type, otp_app: :ex_oauth2_provider) == {:error, expected_error, :unprocessable_entity} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/plug/ensure_authenticated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.EnsureAuthenticatedTest do 2 | @moduledoc false 3 | use ExOauth2Provider.ConnCase 4 | 5 | alias ExOauth2Provider.{Plug, Plug.EnsureAuthenticated} 6 | alias ExOauth2Provider.Test.Fixtures 7 | 8 | defmodule TestHandler do 9 | @moduledoc false 10 | 11 | def unauthenticated(conn, _) do 12 | assert conn.halted 13 | 14 | :unauthenticated 15 | end 16 | end 17 | 18 | describe "with valid access token doesn't require authentication" do 19 | setup context do 20 | application = Fixtures.application(scopes: "app:read app:write") 21 | access_token = Fixtures.access_token(application: application, scopes: "app:read") 22 | 23 | {:ok, Map.put(context, :access_token, access_token)} 24 | end 25 | 26 | test "with default key", %{conn: conn, access_token: access_token} do 27 | conn = 28 | conn 29 | |> Plug.set_current_access_token({:ok, access_token}) 30 | |> EnsureAuthenticated.call(handler: TestHandler) 31 | 32 | refute conn == :unauthenticated 33 | end 34 | 35 | test "with custom key", %{conn: conn, access_token: access_token} do 36 | conn = 37 | conn 38 | |> Plug.set_current_access_token({:ok, access_token}, :secret) 39 | |> EnsureAuthenticated.call(handler: TestHandler, key: :secret) 40 | 41 | refute conn == :unauthenticated 42 | end 43 | end 44 | 45 | describe "without valid access token" do 46 | test "requires authentication with default key", %{conn: conn} do 47 | conn = EnsureAuthenticated.call(conn, handler: TestHandler) 48 | 49 | assert conn == :unauthenticated 50 | end 51 | 52 | test "requires authentication with custom key", %{conn: conn} do 53 | conn = EnsureAuthenticated.call(conn, handler: TestHandler, key: :secret) 54 | 55 | assert conn == :unauthenticated 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/plug/ensure_scopes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.EnsureScopesTest do 2 | @moduledoc false 3 | use ExOauth2Provider.ConnCase 4 | 5 | alias ExOauth2Provider.{Plug, Plug.EnsureScopes} 6 | alias Dummy.OauthAccessTokens.OauthAccessToken 7 | 8 | @default_scopes "read write" 9 | 10 | defmodule TestHandler do 11 | @moduledoc false 12 | 13 | def unauthorized(conn, _) do 14 | assert conn.halted 15 | 16 | :forbidden 17 | end 18 | end 19 | 20 | test "is valid when there's no scopes", %{conn: conn} do 21 | conn = run_plug(conn, @default_scopes, scopes: ~w()) 22 | 23 | refute conn == :forbidden 24 | end 25 | 26 | test "is valid when all scopes are present", %{conn: conn} do 27 | conn = run_plug(conn, @default_scopes, scopes: ~w(read write)) 28 | 29 | refute conn == :forbidden 30 | end 31 | 32 | test "is valid when the scope is present", %{conn: conn} do 33 | conn = run_plug(conn, @default_scopes, scopes: ~w(read)) 34 | 35 | refute conn == :forbidden 36 | end 37 | 38 | test "is invalid when all scopes are not present", %{conn: conn} do 39 | conn = run_plug(conn, "read", scopes: ~w(read write)) 40 | 41 | assert conn == :forbidden 42 | end 43 | 44 | test "is invalid when access token doesn't have any required scopes", %{conn: conn} do 45 | conn = run_plug(conn, "other_read", scopes: ~w(read write)) 46 | 47 | assert conn == :forbidden 48 | end 49 | 50 | test "is invalid when none of the one_of scopes is present", %{conn: conn} do 51 | conn = run_plug(conn, "other_read", one_of: [~w(other_write), ~w(read write)]) 52 | 53 | assert conn == :forbidden 54 | end 55 | 56 | test "is valid when at least one_of the scopes is present", %{conn: conn} do 57 | conn = run_plug(conn, "other_read", one_of: [~w(other_read), ~w(read write)]) 58 | 59 | refute conn == :forbidden 60 | end 61 | 62 | defp run_plug(conn, scopes, opts) do 63 | access_token = %OauthAccessToken{token: "secret", scopes: scopes} 64 | opts = Keyword.merge([handler: TestHandler], opts) 65 | 66 | conn 67 | |> Plug.set_current_access_token({:ok, access_token}) 68 | |> EnsureScopes.call(opts) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/plug/error_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.ErrorHandlerTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | use Plug.Test 5 | 6 | alias ExOauth2Provider.Plug.ErrorHandler 7 | 8 | setup do 9 | conn = conn(:get, "/foo") 10 | {:ok, %{conn: conn}} 11 | end 12 | 13 | describe "unauthenticated/2" do 14 | test "with text/html accept", %{conn: conn} do 15 | conn = 16 | conn 17 | |> put_req_header("accept", "text/html") 18 | |> ErrorHandler.unauthenticated(%{}) 19 | 20 | assert conn.status == 401 21 | assert content_type(conn.resp_headers) =~ "text/plain" 22 | assert conn.resp_body == "Unauthenticated" 23 | end 24 | 25 | test "with application/json accept", %{conn: conn} do 26 | conn = 27 | conn 28 | |> put_req_header("accept", "application/json") 29 | |> ErrorHandler.unauthenticated(%{}) 30 | 31 | assert conn.status == 401 32 | assert content_type(conn.resp_headers) =~ "application/json" 33 | assert conn.resp_body == Jason.encode!(%{errors: ["Unauthenticated"]}) 34 | end 35 | 36 | test "with no accept header", %{conn: conn} do 37 | conn = ErrorHandler.unauthenticated(conn, %{}) 38 | 39 | assert conn.status == 401 40 | assert content_type(conn.resp_headers) =~ "text/plain" 41 | assert conn.resp_body == "Unauthenticated" 42 | end 43 | end 44 | 45 | describe "unauthorized/2" do 46 | test "with text/html accept", %{conn: conn} do 47 | conn = 48 | conn 49 | |> put_req_header("accept", "text/html") 50 | |> ErrorHandler.unauthorized(%{}) 51 | 52 | assert conn.status == 403 53 | assert content_type(conn.resp_headers) =~ "text/plain" 54 | assert conn.resp_body == "Unauthorized" 55 | end 56 | 57 | test "with application/json accept", %{conn: conn} do 58 | conn = 59 | conn 60 | |> put_req_header("accept", "application/json") 61 | |> ErrorHandler.unauthorized(%{}) 62 | 63 | assert conn.status == 403 64 | assert content_type(conn.resp_headers) =~ "application/json" 65 | assert conn.resp_body == Jason.encode!(%{errors: ["Unauthorized"]}) 66 | end 67 | 68 | test "with no accept header", %{conn: conn} do 69 | conn = ErrorHandler.unauthorized(conn, %{}) 70 | 71 | assert conn.status == 403 72 | assert content_type(conn.resp_headers) =~ "text/plain" 73 | assert conn.resp_body == "Unauthorized" 74 | end 75 | end 76 | 77 | describe "already_authenticated/2" do 78 | test "halts the conn", %{conn: conn} do 79 | conn = ErrorHandler.already_authenticated(conn, %{}) 80 | 81 | assert conn.halted 82 | end 83 | end 84 | 85 | defp content_type(headers) do 86 | headers 87 | |> Enum.filter(fn({k, _}) -> k == "content-type" end) 88 | |> Enum.map(fn({_, v}) -> v end) 89 | |> List.first() 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/plug/verify_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Plug.VerifyHeaderTest do 2 | @moduledoc false 3 | use ExOauth2Provider.ConnCase 4 | 5 | alias Plug.Conn 6 | alias ExOauth2Provider.{Plug, Plug.VerifyHeader} 7 | alias ExOauth2Provider.Test.Fixtures 8 | 9 | test "with no access token at a default location", %{conn: conn} do 10 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider) 11 | conn = VerifyHeader.call(conn, opts) 12 | 13 | refute Plug.authenticated?(conn) 14 | assert Plug.current_access_token(conn) == nil 15 | end 16 | 17 | test "with no access token at a specified location", %{conn: conn} do 18 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, key: :secret) 19 | conn = VerifyHeader.call(conn, opts) 20 | 21 | refute Plug.authenticated?(conn, :secret) 22 | assert Plug.current_access_token(conn, :secret) == nil 23 | end 24 | 25 | describe "with valid access token" do 26 | setup context do 27 | access_token = Fixtures.access_token() 28 | 29 | {:ok, Map.put(context, :access_token, access_token)} 30 | end 31 | 32 | test "at the default location", %{conn: conn, access_token: access_token} do 33 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider) 34 | conn = 35 | conn 36 | |> Conn.put_req_header("authorization", access_token.token) 37 | |> VerifyHeader.call(opts) 38 | 39 | assert Plug.authenticated?(conn) 40 | assert Plug.current_access_token(conn) == access_token 41 | end 42 | 43 | test "at a specified location", %{conn: conn, access_token: access_token} do 44 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, key: :secret) 45 | conn = 46 | conn 47 | |> Conn.put_req_header("authorization", access_token.token) 48 | |> VerifyHeader.call(opts) 49 | 50 | assert Plug.authenticated?(conn, :secret) 51 | assert Plug.current_access_token(conn, :secret) == access_token 52 | end 53 | 54 | test "with a realm specified", %{conn: conn, access_token: access_token} do 55 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Bearer") 56 | conn = 57 | conn 58 | |> Conn.put_req_header("authorization", "Bearer #{access_token.token}") 59 | |> VerifyHeader.call(opts) 60 | 61 | assert Plug.authenticated?(conn) 62 | assert Plug.current_access_token(conn) == access_token 63 | end 64 | 65 | test "with a realm specified and multiple auth headers", %{conn: conn, access_token: access_token} do 66 | another_access_token = Fixtures.access_token() 67 | 68 | opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Client") 69 | conn = 70 | conn 71 | |> Conn.put_req_header("authorization", "Bearer #{access_token.token}") 72 | |> Conn.put_req_header("authorization", "Client #{another_access_token.token}") 73 | |> VerifyHeader.call(opts) 74 | 75 | assert Plug.authenticated?(conn) 76 | assert Plug.current_access_token(conn) == another_access_token 77 | end 78 | 79 | test "pulls different tokens into different locations", %{conn: conn, access_token: access_token} do 80 | another_access_token = Fixtures.access_token() 81 | 82 | req_headers = [ 83 | {"authorization", "Bearer #{access_token.token}"}, 84 | {"authorization", "Client #{another_access_token.token}"} 85 | ] 86 | 87 | opts_1 = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Bearer") 88 | opts_2 = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Client", key: :client) 89 | conn = 90 | conn 91 | |> Map.put(:req_headers, req_headers) 92 | |> VerifyHeader.call(opts_1) 93 | |> VerifyHeader.call(opts_2) 94 | 95 | assert Plug.authenticated?(conn, :client) 96 | assert Plug.current_access_token(conn, :client) == another_access_token 97 | assert Plug.authenticated?(conn) 98 | assert Plug.current_access_token(conn) == access_token 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.PlugTest do 2 | use ExOauth2Provider.ConnCase 3 | 4 | alias ExOauth2Provider.Plug 5 | 6 | test "authenticated?/1", context do 7 | refute Plug.authenticated?(context.conn) 8 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "secret"}) 9 | assert Plug.authenticated?(new_conn) 10 | end 11 | 12 | test "authenticated?/2", context do 13 | refute Plug.authenticated?(context.conn, :secret) 14 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "secret"}, :secret) 15 | assert Plug.authenticated?(new_conn, :secret) 16 | end 17 | 18 | test "current_resource_owner/1 with no resource", context do 19 | assert Plug.current_resource_owner(context.conn) == nil 20 | end 21 | 22 | test "current_resource_owner/1 with error", context do 23 | new_conn = Plug.set_current_access_token(context.conn, {:error, :error}) 24 | assert Plug.current_resource_owner(new_conn) == nil 25 | end 26 | 27 | test "current_resource_owner/1 with resource", context do 28 | new_conn = Plug.set_current_access_token(context.conn, {:ok, %{resource_owner: "user"}}) 29 | assert Plug.current_resource_owner(new_conn) == "user" 30 | end 31 | 32 | test "current_resource_owner/2 with no resource", context do 33 | assert Plug.current_resource_owner(context.conn, :secret) == nil 34 | end 35 | 36 | test "current_resource_owner/2 with resource", context do 37 | new_conn = Plug.set_current_access_token(context.conn, {:ok, %{resource_owner: "user"}}, :secret) 38 | assert Plug.current_resource_owner(new_conn, :secret) == "user" 39 | end 40 | 41 | test "set_current_access_token/2", context do 42 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "token"}) 43 | assert Plug.current_access_token(new_conn) == "token" 44 | end 45 | 46 | test "set_current_access_token/3", context do 47 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "token"}, :secret) 48 | assert Plug.current_access_token(new_conn, :secret) == "token" 49 | end 50 | 51 | test "current_access_token/1 with no token", context do 52 | assert Plug.current_access_token(context.conn) == nil 53 | end 54 | 55 | test "current_access_token/1 with token", context do 56 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "token"}) 57 | assert Plug.current_access_token(new_conn) == "token" 58 | end 59 | 60 | test "current_access_token/1 with error", context do 61 | new_conn = Plug.set_current_access_token(context.conn, {:error, :error}) 62 | assert Plug.current_access_token(new_conn) == nil 63 | end 64 | 65 | test "current_access_token/2 with no token", context do 66 | assert Plug.current_access_token(context.conn, :secret) == nil 67 | end 68 | 69 | test "current_access_token/2 with token", context do 70 | new_conn = Plug.set_current_access_token(context.conn, {:ok, "token"}, :secret) 71 | assert Plug.current_access_token(new_conn, :secret) == "token" 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/redirect_uri_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.RedirectURITest do 2 | use ExUnit.Case 3 | alias ExOauth2Provider.{Config, RedirectURI} 4 | 5 | test "validate/2 native url" do 6 | uri = Config.native_redirect_uri(otp_app: :ex_oauth2_provider) 7 | assert RedirectURI.validate(uri, []) == {:ok, uri} 8 | end 9 | 10 | test "validate/2 rejects blank" do 11 | assert RedirectURI.validate("", []) == {:error, "Redirect URI cannot be blank"} 12 | assert RedirectURI.validate(nil, []) == {:error, "Redirect URI cannot be blank"} 13 | assert RedirectURI.validate(" ", []) == {:error, "Redirect URI cannot be blank"} 14 | end 15 | 16 | test "validate/2 rejects uri with fragment" do 17 | assert RedirectURI.validate("https://app.co/test#fragment", []) == {:error, "Redirect URI cannot contain fragments"} 18 | end 19 | 20 | test "validate/2 rejects uri with missing scheme" do 21 | assert RedirectURI.validate("app.co", []) == {:error, "Redirect URI must be an absolute URI"} 22 | end 23 | 24 | test "validate/2 rejects relative uri" do 25 | assert RedirectURI.validate("/abc/123", []) == {:error, "Redirect URI must be an absolute URI"} 26 | end 27 | 28 | test "validate/2 requires https scheme with `:force_ssl_in_redirect_uri` setting" do 29 | uri = "http://app.co/" 30 | assert RedirectURI.validate(uri, []) == {:error, "Redirect URI must be an HTTPS/SSL URI"} 31 | assert RedirectURI.validate(uri, [force_ssl_in_redirect_uri: false]) == {:ok, uri} 32 | end 33 | 34 | test "validate/2 accepts absolute uri" do 35 | uri = "https://app.co" 36 | assert RedirectURI.validate(uri, []) == {:ok, uri} 37 | uri = "https://app.co/path" 38 | assert RedirectURI.validate(uri, []) == {:ok, uri} 39 | uri = "https://app.co/?query=1" 40 | assert RedirectURI.validate(uri, []) == {:ok, uri} 41 | end 42 | 43 | test "validate/2 with wild card subdomain" do 44 | uri = "https://*.app.co/" 45 | assert RedirectURI.validate(uri, []) == {:ok, uri} 46 | end 47 | 48 | test "validate/2 with private-use uri" do 49 | # RFC Spec - OAuth 2.0 for Native Apps 50 | # https://tools.ietf.org/html/rfc8252#section-7.1 51 | 52 | uri = "com.example.app:/oauth2redirect/example-provider" 53 | assert RedirectURI.validate(uri, []) == {:ok, uri} 54 | end 55 | 56 | test "matches?#true" do 57 | uri = "https://app.co/aaa" 58 | assert RedirectURI.matches?(uri, uri, []) 59 | end 60 | 61 | test "matches?#true with custom match method" do 62 | uri = "https://a.app.co/" 63 | client_uri = "https://*.app.co/" 64 | 65 | assert RedirectURI.matches?(uri, client_uri, redirect_uri_match_fun: fn uri, %{host: "*." <> host} = client_uri, _config -> 66 | String.ends_with?(uri.host, host) && %{uri | query: nil} == %{client_uri | host: uri.host, authority: uri.authority} 67 | end) 68 | end 69 | 70 | test "matches?#true ignores query parameter on comparison" do 71 | assert RedirectURI.matches?("https://app.co/?query=hello", "https://app.co/", []) 72 | end 73 | 74 | test "matches?#false" do 75 | refute RedirectURI.matches?("https://app.co/?query=hello", "https://app.co", []) 76 | end 77 | 78 | test "matches?#false with domains that doesn't start at beginning" do 79 | refute RedirectURI.matches?("https://app.co/?query=hello", "https://example.com?app.co=test", []) 80 | end 81 | 82 | test "valid_for_authorization?#true" do 83 | uri = "https://app.co/aaa" 84 | assert RedirectURI.valid_for_authorization?(uri, uri, []) 85 | end 86 | 87 | test "valid_for_authorization?#false" do 88 | refute RedirectURI.valid_for_authorization?("https://app.co/aaa", "https://app.co/bbb", []) 89 | end 90 | 91 | test "valid_for_authorization?#true with array" do 92 | assert RedirectURI.valid_for_authorization?("https://app.co/aaa", "https://example.com/bbb\nhttps://app.co/aaa", []) 93 | end 94 | 95 | test "valid_for_authorization?#false with invalid uri" do 96 | uri = "https://app.co/aaa?waffles=abc" 97 | refute RedirectURI.valid_for_authorization?(uri, uri, []) 98 | end 99 | 100 | test "uri_with_query/2" do 101 | assert RedirectURI.uri_with_query("https://example.com/", %{parameter: "value"}) == "https://example.com/?parameter=value" 102 | end 103 | 104 | test "uri_with_query/2 rejects nil values" do 105 | assert RedirectURI.uri_with_query("https://example.com/", %{parameter: nil}) == "https://example.com/?" 106 | end 107 | 108 | test "uri_with_query/2 preserves original query parameters" do 109 | uri = RedirectURI.uri_with_query("https://example.com/?query1=value", %{parameter: "value"}) 110 | assert Regex.match?(~r/query1=value/, uri) 111 | assert Regex.match?(~r/parameter=value/, uri) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/scopes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.ScopesTest do 2 | use ExUnit.Case 3 | alias ExOauth2Provider.Scopes 4 | 5 | test "all?#true" do 6 | scopes = ["read", "write", "profile"] 7 | assert Scopes.all?(scopes, ["read", "profile"]) 8 | assert Scopes.all?(scopes, ["write"]) 9 | assert Scopes.all?(scopes, []) 10 | end 11 | 12 | test "all?#false" do 13 | scopes = ["read", "write", "profile"] 14 | refute Scopes.all?(scopes, ["read", "profile", "another_write"]) 15 | refute Scopes.all?(scopes, ["read", "write", "profile", "another_write"]) 16 | end 17 | 18 | test "equal?#true" do 19 | scopes = ["read", "write"] 20 | assert Scopes.equal?(scopes, ["read", "write"]) 21 | assert Scopes.equal?(scopes, ["write", "read"]) 22 | end 23 | 24 | test "equal?#false" do 25 | scopes = ["read", "write"] 26 | refute Scopes.equal?(scopes, ["read", "write", "profile"]) 27 | refute Scopes.equal?(scopes, ["read"]) 28 | refute Scopes.equal?(scopes, []) 29 | end 30 | 31 | test "to_list" do 32 | str = "user:read user:write global_write" 33 | assert Scopes.to_list(str) == ["user:read", "user:write", "global_write"] 34 | assert Scopes.to_list(nil) == [] 35 | end 36 | 37 | test "to_string" do 38 | list = ["user:read", "user:write", "global_write"] 39 | assert Scopes.to_string(list) == "user:read user:write global_write" 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.UtilTest do 2 | use ExUnit.Case 3 | alias ExOauth2Provider.Utils 4 | 5 | test "remove_empty_values/1" do 6 | assert Utils.remove_empty_values(%{one: nil, two: "", three: "test"}) == %{three: "test"} 7 | end 8 | 9 | test "generate_token/0 it generate random token" do 10 | token_1 = Utils.generate_token() 11 | token_2 = Utils.generate_token() 12 | 13 | refute token_1 == token_2 14 | end 15 | 16 | test "generate_token/1 it generate the token with custom length" do 17 | token1 = Utils.generate_token(size: 1) 18 | token2 = Utils.generate_token(size: 2) 19 | 20 | assert String.length(token1) < String.length(token2) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/ex_oauth2_provider_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2ProviderTest do 2 | use ExOauth2Provider.TestCase 3 | doctest ExOauth2Provider 4 | 5 | alias ExOauth2Provider.AccessTokens 6 | alias ExOauth2Provider.Test.Fixtures 7 | alias Dummy.{OauthAccessTokens.OauthAccessToken, Repo} 8 | 9 | describe "authenticate_token/2" do 10 | test "error when invalid" do 11 | assert ExOauth2Provider.authenticate_token(nil, otp_app: :ex_oauth2_provider) == {:error, :token_inaccessible} 12 | assert ExOauth2Provider.authenticate_token("secret", otp_app: :ex_oauth2_provider) == {:error, :token_not_found} 13 | end 14 | 15 | test "authenticates" do 16 | access_token = Fixtures.access_token() 17 | assert ExOauth2Provider.authenticate_token(access_token.token, otp_app: :ex_oauth2_provider) == {:ok, access_token} 18 | assert access_token.resource_owner 19 | end 20 | 21 | test "authenticates with application-wide token" do 22 | application = Fixtures.application() 23 | access_token = Fixtures.application_access_token(application: application) 24 | 25 | assert {:ok, access_token} = ExOauth2Provider.authenticate_token(access_token.token, otp_app: :ex_oauth2_provider) 26 | refute access_token.resource_owner 27 | end 28 | 29 | test "revokes previous refresh token" do 30 | user = Fixtures.resource_owner() 31 | access_token = Fixtures.access_token(resource_owner: user, use_refresh_token: true) 32 | access_token2 = Fixtures.access_token(resource_owner: user, use_refresh_token: true, previous_refresh_token: access_token) 33 | 34 | assert {:ok, access_token} = ExOauth2Provider.authenticate_token(access_token.token, otp_app: :ex_oauth2_provider) 35 | access_token = Repo.get_by(OauthAccessToken, token: access_token.token) 36 | refute AccessTokens.is_revoked?(access_token) 37 | access_token2 = Repo.get_by(OauthAccessToken, token: access_token2.token) 38 | refute "" == access_token2.previous_refresh_token 39 | 40 | assert {:ok, access_token2} = ExOauth2Provider.authenticate_token(access_token2.token, otp_app: :ex_oauth2_provider) 41 | access_token = Repo.get_by(OauthAccessToken, token: access_token.token) 42 | assert AccessTokens.is_revoked?(access_token) 43 | access_token2 = Repo.get_by(OauthAccessToken, token: access_token2.token) 44 | assert "" == access_token2.previous_refresh_token 45 | end 46 | 47 | test "doesn't revoke when refresh_token_revoked_on_use? == false" do 48 | user = Fixtures.resource_owner() 49 | access_token = Fixtures.access_token(resource_owner: user, use_refresh_token: true) 50 | access_token2 = Fixtures.access_token(resource_owner: user, use_refresh_token: true, previous_refresh_token: access_token) 51 | 52 | assert {:ok, access_token2} = ExOauth2Provider.authenticate_token(access_token2.token, otp_app: :ex_oauth2_provider, revoke_refresh_token_on_use: false) 53 | access_token = Repo.get_by(OauthAccessToken, token: access_token.token) 54 | refute AccessTokens.is_revoked?(access_token) 55 | access_token2 = Repo.get_by(OauthAccessToken, token: access_token2.token) 56 | refute "" == access_token2.previous_refresh_token 57 | end 58 | 59 | test "error when expired token" do 60 | access_token = Fixtures.access_token(expires_in: -1) 61 | 62 | assert ExOauth2Provider.authenticate_token(access_token.token, otp_app: :ex_oauth2_provider) == {:error, :token_inaccessible} 63 | end 64 | 65 | test "error when revoked token" do 66 | access_token = Fixtures.access_token() 67 | AccessTokens.revoke(access_token) 68 | 69 | assert ExOauth2Provider.authenticate_token(access_token.token, otp_app: :ex_oauth2_provider) == {:error, :token_inaccessible} 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do 2 | use ExOauth2Provider.Mix.TestCase 3 | 4 | alias Mix.Tasks.ExOauth2Provider.Gen.Migration 5 | 6 | defmodule Repo do 7 | def __adapter__, do: true 8 | def config, do: [priv: "tmp/#{inspect(Migration)}", otp_app: :ex_oauth2_provider] 9 | end 10 | 11 | @tmp_path Path.join(["tmp", inspect(Migration)]) 12 | @migrations_path Path.join(@tmp_path, "migrations") 13 | @options ~w(-r #{inspect Repo}) 14 | 15 | setup do 16 | File.rm_rf!(@tmp_path) 17 | File.mkdir_p!(@tmp_path) 18 | :ok 19 | end 20 | 21 | test "generates migrations" do 22 | File.cd!(@tmp_path, fn -> 23 | Migration.run(@options) 24 | 25 | assert [migration_file] = File.ls!(@migrations_path) 26 | assert String.match?(migration_file, ~r/^\d{14}_create_oauth_tables\.exs$/) 27 | 28 | file = @migrations_path |> Path.join(migration_file) |> File.read!() 29 | 30 | assert file =~ "defmodule #{inspect Repo}.Migrations.CreateOauthTables do" 31 | assert file =~ "use Ecto.Migration" 32 | assert file =~ "def change do" 33 | assert file =~ "add :owner_id, references(:users, on_delete: :nothing)" 34 | assert file =~ "add :resource_owner_id, references(:users, on_delete: :nothing)" 35 | refute file =~ "add :owner_id, references(:users, on_delete: :nothing, type: :binary_id)" 36 | refute file =~ "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" 37 | refute file =~ ":oauth_applications, primary_key: false" 38 | refute file =~ ":oauth_access_grants, primary_key: false" 39 | refute file =~ ":oauth_access_tokens, primary_key: false" 40 | refute file =~ "add :id, :binary_id, primary_key: true" 41 | refute file =~ "add :application_id, references(:oauth_applications, on_delete: :nothing, type: binary_id)" 42 | end) 43 | end 44 | 45 | test "generates migrations with binary id" do 46 | File.cd!(@tmp_path, fn -> 47 | Migration.run(@options ++ ~w(--binary-id)) 48 | 49 | assert [migration_file] = File.ls!(@migrations_path) 50 | 51 | file = @migrations_path |> Path.join(migration_file) |> File.read!() 52 | 53 | refute file =~ "add :owner_id, :integer, null: false" 54 | refute file =~ "add :resource_owner_id, :integer" 55 | assert file =~ "add :owner_id, references(:users, on_delete: :nothing, type: :binary_id)" 56 | assert file =~ "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" 57 | assert file =~ ":oauth_applications, primary_key: false" 58 | assert file =~ ":oauth_access_grants, primary_key: false" 59 | assert file =~ ":oauth_access_tokens, primary_key: false" 60 | assert file =~ "add :id, :binary_id, primary_key: true" 61 | assert file =~ "add :application_id, references(:oauth_applications, on_delete: :nothing, type: :binary_id)" 62 | end) 63 | end 64 | 65 | test "doesn't make duplicate migrations" do 66 | File.cd!(@tmp_path, fn -> 67 | Migration.run(@options) 68 | 69 | assert_raise Mix.Error, "migration can't be created, there is already a migration file with name CreateOauthTables.", fn -> 70 | Migration.run(@options) 71 | end 72 | end) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do 2 | use ExOauth2Provider.Mix.TestCase 3 | 4 | alias Mix.Tasks.ExOauth2Provider.Gen.Schemas 5 | 6 | @tmp_path Path.join(["tmp", inspect(Schemas)]) 7 | @options ~w(--context-app test) 8 | @files ["access_grant", "access_token", "application"] 9 | 10 | setup do 11 | File.rm_rf!(@tmp_path) 12 | File.mkdir_p!(@tmp_path) 13 | 14 | :ok 15 | end 16 | 17 | test "generates files" do 18 | File.cd!(@tmp_path, fn -> 19 | root_path = Path.join(["lib", "test"]) 20 | 21 | Schemas.run(@options) 22 | 23 | for file <- @files do 24 | path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) 25 | 26 | assert File.exists?(path) 27 | 28 | module = Module.concat(["Test", Macro.camelize("oauth_#{file}s"), Macro.camelize("oauth_#{file}")]) 29 | macro = Module.concat(["ExOauth2Provider", Macro.camelize("#{file}s"), Macro.camelize("#{file}")]) 30 | content = File.read!(path) 31 | 32 | assert content =~ "defmodule #{inspect module} do" 33 | assert content =~ "use #{inspect macro}, otp_app: :test" 34 | assert content =~ "schema \"oauth_#{file}s\" do" 35 | assert content =~ "#{file}_fields()" 36 | end 37 | end) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/mix/tasks/ex_oauth2_provider.install_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2Provider.InstallTest do 2 | use ExOauth2Provider.Mix.TestCase 3 | 4 | alias Mix.Tasks.ExOauth2Provider.Install 5 | alias Dummy.Repo 6 | 7 | @tmp_path Path.join(["tmp", inspect(Install)]) 8 | @options ~w(--context-app test -r #{inspect Repo} --no-migration --no-scehmas) 9 | 10 | setup do 11 | File.rm_rf!(@tmp_path) 12 | File.mkdir_p!(@tmp_path) 13 | 14 | :ok 15 | end 16 | 17 | test "prints instructions" do 18 | File.cd!(@tmp_path, fn -> 19 | Install.run(@options) 20 | 21 | assert_received {:mix_shell, :info, ["ExOauth2Provider has been installed! Please append the following to `config/config.ex`:" <> msg]} 22 | 23 | assert msg =~ "config :test, ExOauth2Provider," 24 | assert msg =~ " repo: #{inspect Repo}," 25 | assert msg =~ " resource_owner: Test.Users.User" 26 | end) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/mix/tasks/ex_oauth2_provider_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ExOauth2ProviderTest do 2 | use ExOauth2Provider.Mix.TestCase 3 | 4 | alias Mix.Tasks.ExOauth2Provider 5 | 6 | test "provide a list of available ex_oauth2_provider mix tasks" do 7 | ExOauth2Provider.run([]) 8 | 9 | assert_received {:mix_shell, :info, ["ExOauth2Provider v" <> _]} 10 | assert_received {:mix_shell, :info, ["mix ex_oauth2_provider.install" <> _]} 11 | end 12 | 13 | test "expects no arguments" do 14 | assert_raise Mix.Error, fn -> 15 | ExOauth2Provider.run(["invalid"]) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.Auth do 2 | @moduledoc false 3 | 4 | alias Dummy.{Users.User, Repo} 5 | 6 | def auth(username, password) do 7 | user = Repo.get_by(User, email: username) 8 | 9 | cond do 10 | user == nil -> {:error, :no_user_found} 11 | password == "secret" -> {:ok, user} 12 | true -> {:error, :invalid_password} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.ConnCase do 2 | @moduledoc false 3 | 4 | use ExUnit.CaseTemplate 5 | 6 | alias Ecto.Adapters.SQL.Sandbox 7 | 8 | setup do 9 | :ok = Sandbox.checkout(Dummy.Repo) 10 | Sandbox.mode(Dummy.Repo, {:shared, self()}) 11 | 12 | conn = Plug.Test.conn(:get, "/") 13 | 14 | {:ok, conn: conn} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Test.Fixtures do 2 | @moduledoc false 3 | 4 | alias ExOauth2Provider.AccessTokens 5 | alias Dummy.{OauthApplications.OauthApplication, OauthAccessGrants.OauthAccessGrant, Repo, Users.User} 6 | alias Ecto.Changeset 7 | 8 | def resource_owner(attrs \\ []) do 9 | attrs = Keyword.merge([email: "foo@example.com"], attrs) 10 | 11 | User 12 | |> struct() 13 | |> Changeset.change(attrs) 14 | |> Repo.insert!() 15 | end 16 | 17 | def application(attrs \\ []) do 18 | resource_owner = Keyword.get(attrs, :resource_owner) || resource_owner() 19 | attrs = [ 20 | owner_id: resource_owner.id, 21 | uid: "test", 22 | secret: "secret", 23 | name: "OAuth Application", 24 | redirect_uri: "urn:ietf:wg:oauth:2.0:oob", 25 | scopes: "public read write"] 26 | |> Keyword.merge(attrs) 27 | |> Keyword.drop([:resource_owner]) 28 | 29 | %OauthApplication{} 30 | |> Changeset.change(attrs) 31 | |> Repo.insert!() 32 | end 33 | 34 | def access_token(attrs \\ []) do 35 | {:ok, access_token} = 36 | attrs 37 | |> Keyword.get(:resource_owner) 38 | |> Kernel.||(resource_owner()) 39 | |> AccessTokens.create_token(Enum.into(attrs, %{}), otp_app: :ex_oauth2_provider) 40 | 41 | access_token 42 | end 43 | 44 | def application_access_token(attrs \\ []) do 45 | {:ok, access_token} = 46 | attrs 47 | |> Keyword.get(:application) 48 | |> Kernel.||(application()) 49 | |> AccessTokens.create_application_token(Enum.into(attrs, %{}), otp_app: :ex_oauth2_provider) 50 | 51 | access_token 52 | end 53 | 54 | 55 | def access_grant(application, user, code, redirect_uri) do 56 | attrs = [ 57 | expires_in: 900, 58 | redirect_uri: "urn:ietf:wg:oauth:2.0:oob", 59 | application_id: application.id, 60 | resource_owner_id: user.id, 61 | token: code, 62 | scopes: "read", 63 | redirect_uri: redirect_uri 64 | ] 65 | 66 | %OauthAccessGrant{} 67 | |> Changeset.change(attrs) 68 | |> Repo.insert!() 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/support/lib/dummy/oauth_access_grants/oauth_access_grant.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.OauthAccessGrants.OauthAccessGrant do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | use ExOauth2Provider.AccessGrants.AccessGrant, otp_app: :ex_oauth2_provider 6 | 7 | if System.get_env("UUID") do 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | end 11 | 12 | schema "oauth_access_grants" do 13 | access_grant_fields() 14 | timestamps() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/lib/dummy/oauth_access_tokens/oauth_access_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.OauthAccessTokens.OauthAccessToken do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | use ExOauth2Provider.AccessTokens.AccessToken, otp_app: :ex_oauth2_provider 6 | 7 | if System.get_env("UUID") do 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | end 11 | 12 | schema "oauth_access_tokens" do 13 | access_token_fields() 14 | timestamps() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/lib/dummy/oauth_applications/oauth_application.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.OauthApplications.OauthApplication do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | use ExOauth2Provider.Applications.Application, otp_app: :ex_oauth2_provider 6 | 7 | if System.get_env("UUID") do 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | end 11 | 12 | schema "oauth_applications" do 13 | application_fields() 14 | timestamps() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/lib/dummy/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.Repo do 2 | @moduledoc false 3 | use Ecto.Repo, otp_app: :ex_oauth2_provider, adapter: Ecto.Adapters.Postgres 4 | 5 | def log(_cmd), do: nil 6 | end 7 | -------------------------------------------------------------------------------- /test/support/lib/dummy/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Dummy.Users.User do 2 | @moduledoc false 3 | use Ecto.Schema 4 | 5 | if System.get_env("UUID") do 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | end 9 | 10 | schema "users" do 11 | field :email, :string 12 | has_many :tokens, Dummy.OauthAccessTokens.OauthAccessToken, foreign_key: :resource_owner_id 13 | 14 | timestamps() 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/mix/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Mix.TestCase do 2 | @moduledoc false 3 | use ExUnit.CaseTemplate 4 | 5 | setup_all do 6 | clear_tmp_files() 7 | 8 | :ok 9 | end 10 | 11 | setup _context do 12 | current_shell = Mix.shell() 13 | 14 | on_exit fn -> 15 | Mix.shell(current_shell) 16 | end 17 | 18 | Mix.shell(Mix.Shell.Process) 19 | 20 | :ok 21 | end 22 | 23 | defp clear_tmp_files, do: File.rm_rf!("tmp") 24 | end 25 | -------------------------------------------------------------------------------- /test/support/priv/migrations/1_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Test.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users, primary_key: is_nil(System.get_env("UUID"))) do 6 | if System.get_env("UUID") do 7 | add :id, :binary_id, primary_key: true 8 | end 9 | add :email, :string 10 | 11 | timestamps() 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/priv/migrations/2_create_oauth_tables.exs: -------------------------------------------------------------------------------- 1 | require Mix.ExOauth2Provider.Migration 2 | 3 | binary_id = if System.get_env("UUID"), do: true, else: false 4 | "CreateOauthTables" 5 | |> Mix.ExOauth2Provider.Migration.gen("oauth", %{repo: ExOauth2Provider.Test.Repo, binary_id: binary_id}) 6 | |> Code.eval_string() 7 | -------------------------------------------------------------------------------- /test/support/query_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.Test.QueryHelpers do 2 | @moduledoc false 3 | 4 | alias Dummy.Repo 5 | import Ecto.Query 6 | alias Ecto.Changeset 7 | alias ExOauth2Provider.Schema 8 | 9 | def change!(struct, changes) do 10 | changes = convert_timestamps(changes) 11 | 12 | struct 13 | |> Changeset.change(changes) 14 | |> Repo.update!() 15 | end 16 | 17 | defp convert_timestamps(changes) do 18 | Enum.map(changes, &convert_timestamp/1) 19 | end 20 | 21 | defp convert_timestamp({key, %DateTime{} = value}), do: {key, %{value | microsecond: {0, 0}}} 22 | defp convert_timestamp(any), do: any 23 | 24 | def get_latest_inserted(module) do 25 | module 26 | |> order_by([x], desc: x.id) 27 | |> limit(1) 28 | |> Repo.one() 29 | end 30 | 31 | def timestamp(struct, type, opts \\ []) do 32 | now = Schema.__timestamp_for__(struct, type) 33 | 34 | opts 35 | |> Keyword.get(:seconds) 36 | |> case do 37 | nil -> now 38 | seconds -> now.__struct__.add(now, seconds, :second) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExOauth2Provider.TestCase do 2 | @moduledoc false 3 | 4 | use ExUnit.CaseTemplate 5 | 6 | alias Ecto.Adapters.SQL.Sandbox 7 | 8 | setup do 9 | :ok = Sandbox.checkout(Dummy.Repo) 10 | Sandbox.mode(Dummy.Repo, {:shared, self()}) 11 | 12 | :ok 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :warn) 2 | 3 | ExUnit.start() 4 | 5 | # Ensure that symlink to custom ecto priv directory exists 6 | source = Dummy.Repo.config()[:priv] 7 | target = Application.app_dir(:ex_oauth2_provider, source) 8 | File.rm_rf(target) 9 | File.mkdir_p(target) 10 | File.rmdir(target) 11 | :ok = :file.make_symlink(Path.expand(source), target) 12 | 13 | # Set up database 14 | Mix.Task.run("ecto.drop", ~w(--quiet -r Dummy.Repo)) 15 | Mix.Task.run("ecto.create", ~w(--quiet -r Dummy.Repo)) 16 | Mix.Task.run("ecto.migrate", ~w(-r Dummy.Repo)) 17 | 18 | {:ok, _pid} = Dummy.Repo.start_link() 19 | Ecto.Adapters.SQL.Sandbox.mode(Dummy.Repo, :manual) 20 | --------------------------------------------------------------------------------