Amur

Simple OAuth for Plug applications.

Amur gives Plug applications a small, provider agnostic OAuth flow without requiring Phoenix. It handles the OAuth handshake, state/PKCE, provider specific configuration, and user normalization. All of this while leaving authentication and user data management up to your application.

Why Amur?

Amur sits between your router and your OAuth provider:

Your application
Amur.Router
OAuth handshake
State / PKCE
Provider strategy
User normalization
Your on_success/2
Your authentication system

Amur doesn't create users, manage sessions or impose any authentication systems on your application. It gives you the OAuth result and you decide what happens next.

def deps do
[
{:igniter, "~> 0.8"}
]
end
# Defaults to GitHub
mix igniter.install amur --provider <Your Provider>

Options:

Flag Description
--provider <name> Provider atom used in the generated config (default: github)
--all Generate config for every built-in provider (cannot be combined with --provider)
--app <name> Override the detected app name
--no-config / --no-router / --no-controller Skip individual pieces

Add your secrets into a .env:

GITHUB_CLIENT_ID=<ID>
GITHUB_CLIENT_SECRET=<SECRET>

That's it for the basic OAuth flow.

Amur handles the OAuth handshake, state/PKCE, callback, and user normalization. Edit the generated AuthController to decide how your application creates users, establishes sessions, and redirects authenticated users.

Run the generator from your project root to scaffold the controller, mount the router, and write the config block automatically:

mix amur.gen

It inspects your mix.exs to detect the app name, derives the web module (AppWeb when a Phoenix-style lib/<app>_web layout is present, otherwise App), and writes the boilerplate for you — no prompts. It defaults to the github provider.

mix amur.gen --provider google
mix amur.gen --all

Indepth Setup

1. Configure your OAuth providers

# config/runtime.exs
config :amur,
base_url: System.get_env("BASE_URL") || "http://localhost:4000",
providers: [
github: [
client_id: System.fetch_env!("GITHUB_CLIENT_ID"),
client_secret: System.fetch_env!("GITHUB_CLIENT_SECRET")
]
],
on_success: &MyAppWeb.AuthController.on_success/2,
on_failure: &MyAppWeb.AuthController.on_failure/2
Key Required Description
base_url no Base URL used to build the redirect_uri (#{base_url}/auth/:provider/callback). Defaults to "".
providers yes Keyword list of provider configurations. Each key is a provider name, each value is either a keyword list of credentials or a custom provider module.
on_success yes A {module, function, args} MFA tuple or a function capture of arity 2, called with (conn, %{user: normalized_user, token: token}).
on_failure no Same format as on_success, called with (conn, reason). Defaults to a redirect to /.

2. Mount the router

# Phoenix
scope "/auth", alias: false do
pipe_through :browser
forward "/", Amur.Router
end

The alias: false on the scope is required, without it Phoenix rewrites Amur.Router as YourAppWeb.Amur.Router.

Inside a browser pipeline, session and flash helpers are available for your callbacks.

Amur works with Plug.Router too:

# Plug
forward "/auth", to: Amur.Router

The router exposes three endpoints:

Endpoint Description
GET /auth/:provider Initiates the OAuth flow
GET /auth/:provider/callback Handles the provider callback

Amur stores the OAuth handshake params (the state, PKCE verifier, ...) in the session for the duration of the flow and clears them automatically once the callback has been handled, no manual cleanup needed.

3. Add an auth controller

defmodule MyAppWeb.AuthController do
import Plug.Conn
import Phoenix.Controller
def on_success(conn, %{user: user}) do
conn
|> put_flash(:info, "Logged in as #{user.email}")
|> redirect(to: "/")
|> halt()
end
def on_failure(conn, reason) do
conn
|> put_flash(:error, "Authentication failed")
|> redirect(to: "/")
|> halt()
end
end

The normalized user map has the following shape:

%{
provider: "github", # the provider atom as a string
uid: "12345", # provider-specific user ID
email: "user@example.com",
name: "username",
avatar: "https://..."
}

Different providers may return different fields. See each provider module's normalize_user/1 for the exact shape.

on_success/2 also receives the OAuth token in the same map. Bind it only when you need it (for example, to call the provider's API on the user's behalf); otherwise ignore it by pattern-matching just :user:

def on_success(conn, %{user: user, token: token}) do
conn
|> put_session(:access_token, token["access_token"])
|> redirect(to: "/")
|> halt()
end

The token map's keys depend on the provider's flow: OAuth 2.0 providers use token["access_token"], while OAuth 1.0 (Twitter) uses token["oauth_token"] and token["oauth_token_secret"].

Built-in providers

Amur ships with support for the following providers:

Apple, Auth0, Azure AD, Basecamp, Bitbucket, DigitalOcean, Discord, Facebook, GitHub, GitLab, Google, Hack Club, Instagram, LINE, LinkedIn, Slack, Spotify, Strava, Stripe, Telegram, Twitch, Twitter (X), VK, Zitadel

Each is a thin wrapper around the corresponding Assent strategy.

Custom providers

You can define your own provider module using the Amur.Provider behaviour:

defmodule MyApp.Auth.CustomProvider do
use Amur.Provider
def strategy, do: Assent.Strategy.OAuth2
def base_config do
[
base_url: "https://api.example.com",
authorization_endpoint: "/oauth/authorize",
token_endpoint: "/oauth/token",
user_endpoint: "/user"
]
end
def normalize_user(user) do
%{uid: user["id"], email: user["email"], name: user["name"]}
end
end

Then reference it in your config:

config :amur,
providers: [
my_provider: MyApp.Auth.CustomProvider
]

Scopes

To request specific OAuth scopes, pass them in your provider config:

config :amur,
providers: [
github: [
client_id: "..",
client_secret: "..",
scopes: "user:email,read:org"
]
]

License

MIT