KeenAuth
Super simple, yet super powerful authentication for Phoenix applications.
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:
- Minimal configuration - Get OAuth working with just a few lines of config
- Pipeline architecture - Like Unix pipes for authentication: small, focused components that compose beautifully
- Progressive complexity - Start simple, add sophistication only where you need it
- No magic - Every step is explicit and debuggable
- Full data access - Raw OAuth data (tokens, claims) flows through every stage; nothing is hidden
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
| Component | Purpose | Has Access To |
|---|---|---|
| Strategy | OAuth protocol (via Assent) | Provider config |
| Mapper | Transform external user → your app's format | Raw OAuth user data |
| Processor | Your business logic | Mapped user + raw OAuth response + tokens |
| Storage | Persist the session | Final 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 appInstallation
Add keen_auth to your list of dependencies in mix.exs:
def deps do
[
{:keen_auth, "~> 1.0"}
]
endQuick Start
1. Configuration
Add to your config.exs:
OAuth Scopes
KeenAuth automatically requests
openid profile email offline_accessscopes if you don't specify any. This ensures user profile data is returned by the provider. If you specify custom scopes viaauthorization_params: [scope: "..."], make sure to include at leastopenid profile emailor 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
end3. Endpoint Configuration
Add the KeenAuth plug to your endpoint:
defmodule MyAppWeb.Endpoint do
plug KeenAuth.Plug
plug MyAppWeb.Router
endCustom 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
endAdvanced 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
endCustom 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
endRoute 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
endRole-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
endStorage Options
Session Storage (Default)
# No additional configuration neededDatabase 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...
endJWT 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
endHelper 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
endSign Out
def sign_out(conn, _params) do
conn
|> KeenAuth.Storage.delete()
|> redirect(to: "/")
endConfiguration Reference
OAuth Scopes
Warning
If you override
authorization_paramswith 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_accessWhat each scope provides:
openid- Required for OIDC, returnssub(user ID)profile- Returnsname,preferred_username, etc.email- Returns user's email addressoffline_access- Returns refresh token for token renewal
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:
- Azure Active Directory
- GitHub
- Discord
- And many more
Development
Running Tests
mix deps.get
mix testCode Formatting
mix formatGenerating Documentation
mix docsContributing
- Fork the repository
-
Create your feature branch (
git checkout -b feature/amazing-feature) -
Commit your changes (
git commit -m 'Add amazing feature') -
Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
- Documentation: HexDocs
- Issues: GitHub Issues
- Source: GitHub