Skip to content
 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

330 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ExOauth2Provider

Github CI hex.pm hex.pm downloads

The no-brainer library to use for adding OAuth 2.0 provider capabilities to your Elixir app. You can use phoenix_oauth2_provider for easy integration with your Phoenix app.

This is Freshline's fork of ex_oauth2_provider. It adds, on top of upstream:

  • PKCE (RFC 7636) for the authorization code flow — see PKCE.
  • Extensible schema fields — add your own columns (e.g. a tenant vendor_id) to the grant/token schemas — see Extending the schemas.
  • Per-call repo options (repo_opts) passed through to Ecto — see Per-call repo options.
  • Modernized for Elixir 1.18+ / OTP 28 (uses the built-in JSON module), with a Nix flake dev environment.

Installation

This fork is consumed as a git dependency. Add it to your list of dependencies in mix.exs:

def deps do
  [
    # ...
    {:ex_oauth2_provider, github: "freshlineapp/ex_oauth2_provider", branch: "feat/repo-opts"}
    # ...
  ]
end

Run mix deps.get to install it. Requires Elixir 1.18+.

Getting started

Generate the migrations and schema modules:

mix ex_oauth2_provider.install

Add the following to config/config.exs:

config :my_app, ExOauth2Provider,
  repo: MyApp.Repo,
  resource_owner: MyApp.Users.User

If you don't have any user setup, you shuld consider setting up Pow first.

Authorize code flow

Authorization request

You have to ensure that a resource_owner has been authenticated on the following endpoints, and pass the struct as the first argument in the following methods.

# GET /oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK_URL&scope=read
case ExOauth2Provider.Authorization.preauthorize(resource_owner, params, otp_app: :my_app) do
  {:ok, client, scopes}             -> # render authorization page
  {:redirect, redirect_uri}         -> # redirect to external redirect_uri
  {:native_redirect, %{code: code}} -> # redirect to local :show endpoint
  {:error, error, http_status}      -> # render error page
end

# POST /oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK_URL&scope=read
ExOauth2Provider.Authorization.authorize(resource_owner, params, otp_app: :my_app) do
  {:redirect, redirect_uri}         -> # redirect to external redirect_uri
  {:native_redirect, %{code: code}} -> # redirect to local :show endpoint
  {:error, error, http_status}      -> # render error page
end

# DELETE /oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK_URL&scope=read
ExOauth2Provider.Authorization.deny(resource_owner, params, otp_app: :my_app) do
  {:redirect, redirect_uri}         -> # redirect to external redirect_uri
  {:error, error, http_status}      -> # render error page
end

Authorization code grant

# POST /oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=CALLBACK_URL
case ExOauth2Provider.Token.grant(params, otp_app: :my_app) do
  {:ok, access_token}               -> # JSON response
  {:error, error, http_status}      -> # JSON response
end

PKCE

The authorization code flow supports PKCE (RFC 7636), which is required for public clients (native/single-page apps that can't keep a client secret).

The client generates a random code_verifier and sends its transformed code_challenge on the authorization request:

# GET /oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK_URL&scope=read
#      &code_challenge=CODE_CHALLENGE&code_challenge_method=S256
ExOauth2Provider.Authorization.preauthorize(resource_owner, params, otp_app: :my_app)

Both S256 (recommended) and plain challenge methods are supported. When code_challenge is present but code_challenge_method is omitted, it defaults to plain per RFC 7636. The challenge is stored on the access grant.

At the token endpoint, the client presents the original code_verifier, which is verified (constant-time) against the stored challenge:

# POST /oauth/token?client_id=CLIENT_ID&grant_type=authorization_code&code=AUTHORIZATION_CODE
#       &redirect_uri=CALLBACK_URL&code_verifier=CODE_VERIFIER
case ExOauth2Provider.Token.grant(params, otp_app: :my_app) do
  {:ok, access_token}               -> # JSON response
  {:error, error, http_status}      -> # {:error, :invalid_grant, ...} on verifier mismatch
end

Public clients — applications with no client_secret (secret is nil or "") — are always required to use PKCE: an authorization request from a secretless client without a code_challenge is rejected with invalid_request. This closes the authorization-code-interception hole for clients that can't authenticate at the token endpoint (native/SPA/desktop apps).

Confidential clients (those with a secret) are unaffected: grants issued without a challenge skip verification, so PKCE stays opt-in per authorization request and backwards compatible for them.

To require PKCE for every client on the authorization code flow — confidential ones included — enable pkce_required (default false):

config :my_app, ExOauth2Provider,
  pkce_required: true

Revocation

# GET /oauth/revoke?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&token=ACCESS_TOKEN
case ExOauth2Provider.Token.revoke(params, otp_app: :my_app) do
  {:ok, %{}}                        -> # JSON response
  {:error, error, http_status}      -> # JSON response
end

Revocation will return {:ok, %{}} status even if the token is invalid.

Authorization code flow in a Single Page Application

ExOauth2Provider doesn't support implicit grant flow. Instead you should set up an application with no client secret, and use the Authorize code grant flow. client_secret isn't required unless it has been set for the application. For public clients you should use PKCE to protect the authorization code.

Other supported token grants

Client credentials

# POST /oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=client_credentials
case ExOauth2Provider.Token.grant(params, otp_app: :my_app) do
  {:ok, access_token}               -> # JSON response
  {:error, error, http_status}      -> # JSON response
end

Refresh token

Refresh tokens can be enabled in the configuration:

config :my_app, ExOauth2Provider,
  repo: MyApp.Repo,
  resource_owner: MyApp.Users.User,
  use_refresh_token: true

The refresh_token grant flow will then be enabled.

# POST /oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=refresh_token&refresh_token=REFRESH_TOKEN
case ExOauth2Provider.Token.grant(params, otp_app: :my_app) do
  {:ok, access_token}               -> # JSON response
  {:error, error, http_status}      -> # JSON response
end

Username and password

You'll need to provide an authorization method that accepts username and password as arguments, and returns {:ok, resource_owner} or {:error, reason}. Here'a an example:

# Configuration in config/config.exs
config :my_app, ExOauth2Provider,
  password_auth: {Auth, :authenticate}

# Module example
defmodule Auth do
  def authenticate(username, password, otp_app: :my_app) do
    User
    |> Repo.get_by(email: username)
    |> verify_password(password)
  end

  defp verify_password(nil, password) do
    check_pw("", password) # Prevent timing attack

    {:error, :no_user_found}
  end
  defp verify_password(%{password_hash: password_hash} = user, password) do
    case check_pw(password_hash, password) do
      true  -> {:ok, user}
      false -> {:error, :invalid_password}
    end
  end
end

The password grant flow will then be enabled.

# POST /oauth/token?client_id=CLIENT_ID&grant_type=password&username=USERNAME&password=PASSWORD
case ExOauth2Provider.Token.grant(params, otp_app: :my_app) do
  {:ok, access_token}               -> # JSON response
  {:error, error, http_status}      -> # JSON response
end

Scopes

Server wide scopes can be defined in the configuration:

config :my_app, ExOauth2Provider,
  repo: MyApp.Repo,
  resource_owner: MyApp.Users.User,
  default_scopes: ~w(public),
  optional_scopes: ~w(read update)

Plug API

Looks for a token in the Authorization Header. If one is not found, this does nothing. This will always be necessary to run to load access token and resource owner.

Looks for a verified token loaded by VerifyHeader. If one is not found it will call the :unauthenticated method in the :handler module.

You can use a custom :handler as part of a pipeline, or inside a Phoenix controller like so:

defmodule MyAppWeb.MyController do
  use MyAppWeb, :controller

  plug ExOauth2Provider.Plug.EnsureAuthenticated,
    handler: MyAppWeb.MyAuthErrorHandler
end

The :handler module always defaults to ExOauth2Provider.Plug.ErrorHandler.

Looks for a previously verified token. If one is found, confirms that all listed scopes are present in the token. If not, the :unauthorized function is called on your :handler.

defmodule MyAppWeb.MyController do
  use MyAppWeb, :controller

  plug ExOauth2Provider.Plug.EnsureScopes,
    handler: MyAppWeb.MyAuthErrorHandler, scopes: ~w(read write)
end

When scopes' sets are specified through a :one_of map, the token is searched for at least one matching scopes set to allow the request. The first set that matches will allow the request. If no set matches, the :unauthorized function is called.

defmodule MyAppWeb.MyController do
  use MyAppWeb, :controller

  plug ExOauth2Provider.Plug.EnsureScopes,
    handler: MyAppWeb.MyAuthErrorHandler,
    one_of: [~w(admin), ~w(read write)]
end

Current resource owner and access token

If the Authorization Header was verified, you'll be able to retrieve the current resource owner or access token.

ExOauth2Provider.Plug.current_access_token(conn) # access the token in the `:default` location
ExOauth2Provider.Plug.current_access_token(conn, :custom) # access the token in the `:custom` location if set as `:key` option in `plug ExOauth2Provider.Plug.VerifyHeader`
ExOauth2Provider.Plug.current_resource_owner(conn) # Access the loaded resource owner in the `:default` location
ExOauth2Provider.Plug.current_resource_owner(conn, :custom) # Access the loaded resource owner in the `:secret` location if set as `:key` option in `plug ExOauth2Provider.Plug.VerifyHeader`

Custom access token generator

You can add your own access token generator, as this example shows:

# config/config.exs
config :my_app, ExOauth2Provider,
  access_token_generator: {AccessToken, :new}

defmodule AccessToken
  def new(access_token) do
    with_signer(%JWT.token{
      resource_owner_id: access_token.resource_owner_id,
      application_id: access_token.application.id,
      scopes: access_token.scopes,
      expires_in: access_token.expires_in,
      created_at: access_token.created_at
    }, hs256("my_secret"))
  end
end

Remember to change the field type for the token column in the oauth_access_tokens table to accepts tokens larger than 255 characters.

Custom access token response body

You can add extra values to the response body.

# config/config.exs
config :my_app, ExOauth2Provider,
  access_token_response_body_handler: {CustomResponse, :response}

defmodule CustomResponse
  def response(response_body, access_token) do
    Map.merge(response_body, %{user_id: access_token.resource_owner.id})
  end
end

Remember to change the field type for the token column in the oauth_access_tokens table to accepts tokens larger than 255 characters.

Using binary id

Generate migration file with binary id

You'll need to create the migration file and schema modules with the argument --binary-id:

mix ex_oauth2_provider.install --binary-id

Extending the schemas

The generated schema modules implement the ExOauth2Provider.Changeset behaviour, exposing three callbacks that control which fields the changesets cast, require, and read from the request params:

@callback allowed_fields() :: [atom()]   # fields cast by the changeset
@callback required_fields() :: [atom()]  # fields validated as required
@callback request_fields() :: [binary()] # request params copied onto the record

Each schema also imports helpers returning the library defaults (access_grant_allowed_fields/0, access_token_request_fields/0, etc.), so you can extend rather than replace them. For example, to add a tenant vendor_id column to access grants:

defmodule MyApp.OauthAccessGrants.OauthAccessGrant do
  use Ecto.Schema
  use ExOauth2Provider.AccessGrants.AccessGrant, otp_app: :my_app

  @impl ExOauth2Provider.Changeset
  def allowed_fields, do: [:vendor_id | access_grant_allowed_fields()]

  @impl ExOauth2Provider.Changeset
  def required_fields, do: [:vendor_id | access_grant_required_fields()]

  @impl ExOauth2Provider.Changeset
  def request_fields, do: ["vendor_id" | access_grant_request_fields()]

  schema "oauth_access_grants" do
    belongs_to(:vendor, MyApp.Vendors.Vendor)

    access_grant_fields()

    timestamps()
  end
end

The same pattern applies to OauthAccessToken and OauthApplication. Remember to add any new columns in your migration.

Per-call repo options

ExOauth2Provider.authenticate_token/3 and the underlying repo operations accept a keyword list of options, including :repo_opts, which are passed straight through to the Ecto.Repo calls (get_by, preload, etc.). This is useful when your repo injects behaviour that you need to control per call — for example a multi-tenant repo that scopes every query by vendor_id:

# Look a token up without the repo's tenant scoping (the token itself carries the tenant).
ExOauth2Provider.authenticate_token(token, [otp_app: :my_app], repo_opts: [skip_vendor_id: true])

The ExOauth2Provider.Plug.VerifyHeader plug forwards its options through to authenticate_token/3, so the same :repo_opts can be set on the plug.

Client lookup

The authorize and token flows expose no separate options argument — they take only config — so for those, :repo_opts is read from config itself and applied to the application (client) lookup:

# Resolve the client without the repo's tenant scoping, because `uid` is globally
# unique and this client row may not belong to any single tenant.
ExOauth2Provider.Authorization.preauthorize(resource_owner, request,
  otp_app: :my_app,
  repo_opts: [skip_vendor_id: true]
)

ExOauth2Provider.Token.grant(request, otp_app: :my_app, repo_opts: [skip_vendor_id: true])

This covers:

  • Applications.get_application/2 — used by preauthorize/3, authorize/3 and deny/3
  • Applications.load_application/3 — used by the token grants
  • the preload(:application) in the authorization-code, refresh-token and revoke strategies, which loads the same client row a second time and would otherwise silently resolve to nil

The preload(:resource_owner) calls alongside them are deliberately not covered: a grant's resource owner is already the right tenant's, so that lookup should stay scoped.

Leaving the preloads out is a subtle failure. The client lookup succeeds, the grant is found, and then application_id comes back nil and the insert fails on a not-null constraint far from the cause.

Unlike every other configuration key, :repo_opts is read only from the config keyword list and never from the application environment. Relaxing repo behaviour globally would silently affect every query, so it has to be an explicit per-call decision.

Development

This repo ships a Nix flake providing Elixir 1.19 / OTP 28 and PostgreSQL. Enter the dev shell with:

nix develop   # or `direnv allow` if you use direnv (an .envrc with `use flake` is provided)

The shell prints the available helpers:

pg-start        # start a project-local PostgreSQL (data in ./.nix/pgdata, 127.0.0.1:5433)
pg-stop         # stop it
mix deps.get
mix test        # POSTGRES_URL is preset for the test database

Acknowledgement

This library was made thanks to doorkeeper, guardian and authable, that gave the conceptual building blocks.

Thanks to Benjamin Schultzer for helping to refactor the code.

LICENSE

(The MIT License)

Copyright (c) 2017-2019 Dan Schultzer & the Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

About

Making OAuth 2 provider and authentication with http bearer as simple as possible for Elixir and Phoenix apps

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages