An Elixir library for adding OAuth 2.0 and OpenID provider capabilities to your Elixir app.
Features include:
- OAuth 2.0
- Proof Key for Code Exchange (PKCE)
- OpenID Connect 1.0
Add Zoth to your list of dependencies in mix.exs:
def deps do
[
# ...
{:zoth, "~> 1.0.0"}
# ...
]
endRun mix deps.get to install it.
Generate the migrations and schema modules:
mix zoth.installAdd the following to config/config.ex:
config :my_app, Zoth,
repo: MyApp.Repo,
resource_owner: MyApp.Users.UserYou 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 Zoth.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
Zoth.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
Zoth.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# POST /oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=CALLBACK_URL
case Zoth.Token.grant(params, otp_app: :my_app) do
{:ok, access_token} -> # JSON response
{:error, error, http_status} -> # JSON response
end
# GET /oauth/revoke?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&token=ACCESS_TOKEN
case Zoth.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.
PKCE is supported and disabled by default. You can configure PKCE support for the application or you can manually specify PKCE support by passing configuration explicitly. You can also override the application config by passing configuration explicitly.
The following values are supported:
:enabled- PKCE is required for the authorization code flow and both plain and S256 are allowed.:plain_only- Same as:enabledexcept only plain challenges are allowed.:s256_only- Same as:enabledexcept only S256 challenges are allowed.:disabled- PKCE is disabled an the respective fields are ignored.
To configure for your application:
config :my_app, Zoth, pkce: :enabled,Or - specify manually in any call related to the flow:
case Zoth.Authorization.preauthorize(resource_owner, params, otp_app: :my_app, pkce: :enabled) do
# handle as you see fit...
endYou can refer to RFC-7636 for more about PKCE
In order to use PKCE you must add the fields to the access grants table. You can run the following mix task within your application which will generate the migration for you. If you have a custom table name the task supports this too.
mix zoth.add_pkce_fields -r MyApp.Repo
mix zoth.add_pkce_fields -r MyApp.Repo --apps-table my_custom_table_name
OpenID can be enabled depending on your needs. It currently is configurable
at the application level and relies on scopes. The claims request param
is not supported yet. In order to enable OpenID for an application you must
do at least the following:
If you're codebase relies on a prior version of ExOauth2Provider or Zoth and your tables do not already have the OpenID fields then you can generate the migration with the following command. It also supports custom table names so if you have different names for your apps and/or grants tables you can specify those in the command args.
mix zoth.add_open_id_fields -r MyApp.Repo
When you create your app you must define openid along with any other scopes
that you need. If your app already exists then you must add it.
Your implementation of OpenID must be defined in your apps configuration. You
specify the configuration under the open_id key at the base level.
At a minimum you must specify:
- id_token_issuer
- id_token_signing_key_algorithm
- id_token_signing_key_pem
Optionally you can also specify:
- claims
- id_token_lifespan
- id_token_signing_key_id
config :my_app, ExOauth2Provider,
open_id: %{
claims: [
%{
name: :email,
includes: [%{name: :email_verified}]
}
],
id_token_issuer: "https://my-app.com",
id_token_signing_key_algorithm: "RS256",
id_token_signing_key_id: "abc123",
id_token_signing_key_pem: File.read!("/path/to/my/file.pem")
}The identity token returned contains the minimum required claims to support OpenID. If there are addional claims you wish to support then you must define them in your config and as scopes.
When you define claims in the config this makes them available for use. However that claim must also have been present as a scope when grant was created in order for it to be included in the identity token.
- Update (or create) your application(S) with the scopes needed to support the claims.
- Add the claims in the app config.
- The claim name must match the scope name.
By default the claim value is derived from the resource owner struct. If the struct has an attribute with the same name then the value is used.
If the case your struct has the attributed named differently than the claim then
you can use the alias option. This means that the value will be derived from
the resource owner struct using the alias rather than the name.
# Return the work_email_address value from the resource owner struct for the
# email claim.
open_id: %{
claims: [
%{
name: :email,
alias: :work_email_address
}
],
You can also provide a default value in case a resource owner struct does not have the attribute. For example, in an app that uses different structs as resource owners but some of those structs do not have the attribute. You can define a default for that.
open_id: %{
claims: [
%{
name: :email,
value_when_missing: "N/A"
}
],
Sometimes it's nice to be able to perform a runtime calculation to determine
what the claim value should be. You can do this using the transformer option.
open_id: %{
claims: [
%{
name: :age,
transformer: fn source ->
case source do
%{age: nil} -> "unknown"
%{age: age} -> age
end
end
}
],
Zoth 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.
# POST /oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=client_credentials
case Zoth.Token.grant(params, otp_app: :my_app) do
{:ok, access_token} -> # JSON response
{:error, error, http_status} -> # JSON response
end
Refresh tokens can be enabled in the configuration:
config :my_app, Zoth,
repo: MyApp.Repo,
resource_owner: MyApp.Users.User,
use_refresh_token: trueThe 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 Zoth.Token.grant(params, otp_app: :my_app) do
{:ok, access_token} -> # JSON response
{:error, error, http_status} -> # JSON response
end
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, Zoth,
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
endThe password grant flow will then be enabled.
# POST /oauth/token?client_id=CLIENT_ID&grant_type=password&username=USERNAME&password=PASSWORD
case Zoth.Token.grant(params, otp_app: :my_app) do
{:ok, access_token} -> # JSON response
{:error, error, http_status} -> # JSON response
end
Server wide scopes can be defined in the configuration:
config :my_app, Zoth,
repo: MyApp.Repo,
resource_owner: MyApp.Users.User,
default_scopes: ~w(public),
optional_scopes: ~w(read update)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 Zoth.Plug.EnsureAuthenticated,
handler: MyAppWeb.MyAuthErrorHandler
endThe :handler module always defaults to Zoth.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 Zoth.Plug.EnsureScopes,
handler: MyAppWeb.MyAuthErrorHandler, scopes: ~w(read write)
endWhen 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 Zoth.Plug.EnsureScopes,
handler: MyAppWeb.MyAuthErrorHandler,
one_of: [~w(admin), ~w(read write)]
endIf the Authorization Header was verified, you'll be able to retrieve the current resource owner or access token.
Zoth.Plug.current_access_token(conn) # access the token in the default location
Zoth.Plug.current_access_token(conn, :secret) # access the token in the secret locationZoth.Plug.current_resource_owner(conn) # Access the loaded resource owner in the default location
Zoth.Plug.current_resource_owner(conn, :secret) # Access the loaded resource owner in the secret locationYou can add your own access token generator, as this example shows:
# config/config.exs
config :my_app, Zoth,
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
endRemember to change the field type for the token column in the oauth_access_tokens table to accepts tokens larger than 255 characters.
You can add extra values to the response body.
# config/config.exs
config :my_app, Zoth,
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
endRemember to change the field type for the token column in the oauth_access_tokens table to accepts tokens larger than 255 characters.
You'll need to create the migration file and schema modules with the argument --binary-id:
mix zoth.install --binary-idThe code in this library is based on the ExOauth2Provider library by Dan Schultzer.
(The MIT License)
Copyright (c) 2017-2019 Dan Schultzer & the Contributors
Copyright (c) 2026 Jeff McKenzie
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.