KeenAuth

Super simple, yet super powerful authentication for Phoenix applications.

Hex.pmDocs

Philosophy

KeenAuth is built on one core belief: authentication shouldn't be complicated.

Most auth libraries force you to choose between "simple but limited" or "powerful but complex". KeenAuth gives you both - start with a 10-line configuration and scale to enterprise-grade features without rewriting anything.

Key principles:

Architecture Overview

KeenAuth follows a pipeline approach where each authentication stage can be customized independently:

flowchart LR
User([User]) --> Strategy
Strategy[Strategy<br/><small>OAuth protocol</small>] --> Mapper[Mapper<br/><small>Normalize data</small>]
Mapper --> Processor[Processor<br/><small>Business logic</small>]
Processor --> Storage[Storage<br/><small>Persist session</small>]
Storage --> Done([Authenticated!])

Core Components

ComponentPurposeHas Access To
StrategyOAuth protocol (via Assent)Provider config
MapperTransform external user → your app's formatRaw OAuth user data
ProcessorYour business logicMapped user + raw OAuth response + tokens
StoragePersist the sessionFinal user + tokens

Key insight: Raw data flows through the entire pipeline. Your Processor receives both the mapped user AND the original OAuth response (tokens, raw claims, etc.). Nothing is hidden - you always have access to everything you need.

What Each Stage Does

┌─────────────────────────────────────────────────────────────────────────┐
│ MAPPER │
│ ─────── │
│ Input: Raw user from OAuth provider (provider-specific format) │
│ Output: Normalized user for YOUR app (consistent format) │
│ │
│ Example: Azure returns "userPrincipalName", GitHub returns "login" │
│ → Mapper converts both to your app's "email" / "username" │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ PROCESSOR │
│ ───────── │
│ Input: Mapped user + FULL OAuth response (tokens, raw user, claims) │
│ Output: Final user (after your business logic) │
│ │
│ This is YOUR code. Create DB records, validate domains, assign roles, │
│ call external APIs, reject users - whatever your app needs. │
│ You have access to EVERYTHING: mapped user, raw claims, all tokens. │
└─────────────────────────────────────────────────────────────────────────┘

Start Simple, Scale Up

Level 1: Just make it work (5 minutes)

# config.exs - that's it!
config :my_app, :keen_auth,
strategies: [
github: [
strategy: Assent.Strategy.Github,
config: [client_id: "...", client_secret: "..."]
]
]

Level 2: Add business logic (when you need it)

# Add a processor to validate users, create accounts, assign roles
github: [
strategy: Assent.Strategy.Github,
processor: MyApp.Auth.Processor, # Your business logic
config: [...]
]

Level 3: Full customization (enterprise scenarios)

# Custom mapper for Graph API enrichment, custom storage for distributed sessions
github: [
strategy: Assent.Strategy.Github,
mapper: MyApp.Auth.GraphMapper, # Enrich with external APIs
processor: MyApp.Auth.Processor, # Complex validation & roles
storage: MyApp.Auth.RedisStorage, # Distributed session storage
config: [...]
]

Each level builds on the previous - no rewrites, just additions.

Basic Flow Diagram

flowchart LR
A[Strategy<br/>OAuth flow<br/>Token exchange] --> B[Mapper<br/>Normalize<br/>user data]
B --> C[Processor<br/>Business logic<br/>validation]
C --> D[Storage<br/>Persist<br/>user data]

Advanced Example: Azure AD with Graph API

Pipeline Flow

flowchart LR
A[Strategy<br/>Azure AD OAuth<br/>Get tokens] --> B[Mapper<br/>• Normalize fields<br/>• Call Graph API<br/>• Fetch user groups<br/>• Get manager info]
B --> C[Processor<br/>• Validate domain<br/>• Check permissions<br/>• Create/update user<br/>• Assign roles]
C --> D[Storage<br/>Database +<br/>Session +<br/>JWT token]

Detailed Interaction Flow

sequenceDiagram
participant User
participant KeenAuth
participant AzureAD
participant GraphAPI
participant Database
participant Storage as Storage (Session/ETS/Redis/...)
User->>KeenAuth: Login request
KeenAuth->>AzureAD: Redirect to OAuth
AzureAD->>User: Login form
User->>AzureAD: Credentials
AzureAD->>KeenAuth: OAuth callback with code
Note over KeenAuth: Strategy Phase
KeenAuth->>AzureAD: Exchange code for tokens
AzureAD->>KeenAuth: Access token + User info
Note over KeenAuth: Mapper Phase
KeenAuth->>GraphAPI: Get user groups (with token)
GraphAPI->>KeenAuth: User groups
KeenAuth->>GraphAPI: Get manager info
GraphAPI->>KeenAuth: Manager details
KeenAuth->>GraphAPI: Get user photo
GraphAPI->>KeenAuth: Profile photo
Note over KeenAuth: Processor Phase
KeenAuth->>Database: Check/create user
Database->>KeenAuth: User record
KeenAuth->>Database: Update user roles
Database->>KeenAuth: Updated user
Note over KeenAuth: Storage Phase
KeenAuth->>Storage: Persist user & tokens
KeenAuth->>User: Set cookie/token
KeenAuth->>User: Redirect to app

Installation

Add keen_auth to your list of dependencies in mix.exs:

def deps do
[
{:keen_auth, "~> 1.0"}
]
end

Quick Start

1. Configuration

Add to your config.exs:

OAuth Scopes {: .info}

KeenAuth automatically requests openid profile email offline_access scopes if you don't specify any. This ensures user profile data is returned by the provider. If you specify custom scopes via authorization_params: [scope: "..."], make sure to include at least openid profile email or you may receive empty user data.

config :keen_auth,
strategies: [
azure_ad: [
strategy: Assent.Strategy.AzureAD,
mapper: KeenAuth.Mappers.AzureAD,
processor: MyApp.Auth.Processor,
config: [
tenant_id: System.get_env("AZURE_TENANT_ID"),
client_id: System.get_env("AZURE_CLIENT_ID"),
client_secret: System.get_env("AZURE_CLIENT_SECRET"),
redirect_uri: "https://myapp.com/auth/azure_ad/callback"
]
],
github: [
strategy: Assent.Strategy.Github,
mapper: KeenAuth.Mappers.Github,
processor: MyApp.Auth.Processor,
config: [
client_id: System.get_env("GITHUB_CLIENT_ID"),
client_secret: System.get_env("GITHUB_CLIENT_SECRET"),
redirect_uri: "https://myapp.com/auth/github/callback"
]
]
]

2. Router Setup

defmodule MyAppWeb.Router do
require KeenAuth
pipeline :auth do
plug :fetch_session
plug KeenAuth.Plug.FetchUser
end
scope "/auth" do
pipe_through :auth
KeenAuth.authentication_routes()
end
end

3. Endpoint Configuration

Add the KeenAuth plug to your endpoint:

defmodule MyAppWeb.Endpoint do
plug KeenAuth.Plug
plug MyAppWeb.Router
end

Custom Implementation Examples

Basic Processor

defmodule MyApp.Auth.Processor do
@behaviour KeenAuth.Processor
def process(conn, provider, mapped_user, oauth_response) do
# Simple pass-through
{:ok, conn, mapped_user, oauth_response}
end
def sign_out(conn, provider, params) do
conn
|> KeenAuth.Storage.delete()
|> Phoenix.Controller.redirect(to: "/")
end
end

Advanced Processor with Database Integration

defmodule MyApp.Auth.Processor do
@behaviour KeenAuth.Processor
alias MyApp.{Accounts, Repo}
def process(conn, provider, mapped_user, oauth_response) do
with {:ok, user} <- find_or_create_user(mapped_user),
:ok <- validate_user_permissions(user),
{:ok, user} <- assign_user_roles(user, oauth_response) do
{:ok, conn, user, oauth_response}
else
{:error, reason} ->
conn
|> Phoenix.Controller.put_flash(:error, "Authentication failed: #{reason}")
|> Phoenix.Controller.redirect(to: "/login")
end
end
defp find_or_create_user(mapped_user) do
case Accounts.get_user_by_email(mapped_user.email) do
nil -> Accounts.create_user(mapped_user)
user -> {:ok, user}
end
end
defp validate_user_permissions(user) do
if user.active and valid_domain?(user.email) do
:ok
else
{:error, "Access denied"}
end
end
defp assign_user_roles(user, %{groups: groups}) do
# Assign roles based on Azure AD groups
roles = map_groups_to_roles(groups)
Accounts.update_user_roles(user, roles)
end
end

Custom Mapper with Graph API Integration

defmodule MyApp.Auth.AzureADMapper do
@behaviour KeenAuth.Mapper
alias MyApp.GraphAPI
def map(provider, user_data) do
# Start with basic normalization
base_user = %{
email: user_data["userPrincipalName"],
name: user_data["displayName"],
provider: provider
}
# Enrich with Graph API data
with {:ok, token} <- get_graph_token(),
{:ok, groups} <- GraphAPI.get_user_groups(token, base_user.email),
{:ok, manager} <- GraphAPI.get_user_manager(token, base_user.email),
{:ok, photo} <- GraphAPI.get_user_photo(token, base_user.email) do
base_user
|> Map.put(:groups, groups)
|> Map.put(:manager, manager)
|> Map.put(:profile_photo, photo)
else
_ -> base_user # Fallback to basic data if enrichment fails
end
end
end

Route Protection

Require Authentication

pipeline :authenticated do
plug KeenAuth.Plug.RequireAuthenticated, redirect: "/login"
end
scope "/admin", MyAppWeb do
pipe_through [:browser, :authenticated]
get "/dashboard", AdminController, :dashboard
end

Role-Based Authorization

pipeline :admin_required do
plug KeenAuth.Plug.RequireAuthenticated
plug KeenAuth.Plug.Authorize.Roles, roles: ["admin", "super_admin"]
end
scope "/admin", MyAppWeb do
pipe_through [:browser, :admin_required]
resources "/users", UserController
end

Storage Options

Session Storage (Default)

# No additional configuration needed

Database Storage

defmodule MyApp.Auth.DatabaseStorage do
@behaviour KeenAuth.Storage
alias MyApp.{Accounts, Sessions}
def store(conn, provider, user, oauth_response) do
with {:ok, session} <- Sessions.create_session(user, provider),
conn <- Plug.Conn.put_session(conn, :session_id, session.id) do
{:ok, conn}
end
end
def current_user(conn) do
with session_id when not is_nil(session_id) <- Plug.Conn.get_session(conn, :session_id),
%{user: user} <- Sessions.get_session(session_id) do
user
else
_ -> nil
end
end
# Implement other required callbacks...
end

JWT Token Storage

defmodule MyApp.Auth.JWTStorage do
@behaviour KeenAuth.Storage
use Joken.Config
def store(conn, provider, user, oauth_response) do
token = generate_and_sign!(%{user_id: user.id, provider: provider})
conn =
conn
|> Plug.Conn.put_resp_cookie("auth_token", token, http_only: true, secure: true)
|> KeenAuth.assign_current_user(user)
{:ok, conn}
end
end

Helper Functions

Check Authentication Status

# In your controllers or views
if KeenAuth.authenticated?(conn) do
current_user = KeenAuth.current_user(conn)
# User is authenticated
else
# User is not authenticated
end

Sign Out

def sign_out(conn, _params) do
conn
|> KeenAuth.Storage.delete()
|> redirect(to: "/")
end

Configuration Reference

OAuth Scopes

Warning {: .warning}

If you override authorization_params with custom scopes, you must include the essential OIDC scopes or you will receive empty user data from the provider.

KeenAuth automatically includes these default scopes when none are specified:

openid profile email offline_access

What each scope provides:

Custom scopes example:

# If you need additional scopes (e.g., Microsoft Graph API access),
# always include the base OIDC scopes:
config: [
authorization_params: [
scope: "openid profile email offline_access User.Read Directory.Read.All"
]
]

Complete Configuration Example

config :keen_auth,
# Optional: Custom storage implementation
storage: MyApp.Auth.CustomStorage,
# Optional: Global unauthorized redirect
unauthorized_redirect: "/login",
# OAuth strategies
strategies: [
azure_ad: [
strategy: Assent.Strategy.AzureAD,
mapper: MyApp.Auth.AzureADMapper,
processor: MyApp.Auth.Processor,
config: [
tenant_id: System.get_env("AZURE_TENANT_ID"),
client_id: System.get_env("AZURE_CLIENT_ID"),
client_secret: System.get_env("AZURE_CLIENT_SECRET"),
redirect_uri: "https://myapp.com/auth/azure_ad/callback",
scope: "openid profile email User.Read"
]
],
google: [
strategy: Assent.Strategy.Google,
mapper: KeenAuth.Mappers.Default,
processor: MyApp.Auth.Processor,
config: [
client_id: System.get_env("GOOGLE_CLIENT_ID"),
client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
redirect_uri: "https://myapp.com/auth/google/callback"
]
]
]

Supported Providers

KeenAuth supports all OAuth providers available through the Assent library:

Development

Running Tests

mix deps.get
mix test

Code Formatting

mix format

Generating Documentation

mix docs

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Support