Packages

Library faciliating OpenID authentication flow throughout Phoenix application(s)

Current section

Files

Jump to
keen_auth lib plug auth_session.ex
Raw

lib/plug/auth_session.ex

defmodule KeenAuth.Plug.AuthSession do
@moduledoc """
A separate session cookie for OAuth authentication flow.
This plug creates a dedicated short-lived session cookie for storing OAuth state
(nonce, PKCE verifier, redirect URL) during the authentication flow. This provides
several security benefits:
## Why Dual Cookies?
OAuth providers like Microsoft Entra can use `form_post` response mode (POST callback)
which is more secure than `query` mode (tokens don't appear in URL/browser history).
However, `SameSite=Lax` cookies are NOT sent on cross-site POST requests.
The solution is two cookies:
- **Auth cookie**: `SameSite=None; Secure` - sent on cross-site POSTs, short-lived (5 min)
- **Main cookie**: `SameSite=Lax` or `Strict` - not sent cross-site, long-lived
## Security Benefits
- **Session fixation prevention**: New session ID after authentication
- **Minimal exposure window**: Auth cookie expires in 5 minutes
- **Token security**: Allows `form_post` mode (tokens not in URL)
- **Cookie isolation**: Auth state separate from user session
## Usage
Add to your router's authentication pipeline:
pipeline :authentication do
plug KeenAuth.Plug, otp_app: :my_app
plug KeenAuth.Plug.AuthSession
end
## Configuration
Configure in your endpoint or via plug options:
plug KeenAuth.Plug.AuthSession,
key: "_my_app_auth",
signing_salt: "auth_signing_salt",
encryption_salt: "auth_encryption_salt",
same_site: "None", # Required for cross-site POST
secure: true, # Required with SameSite=None
max_age: 300 # 5 minutes
## How It Works
1. OAuth `/new` → State stored in auth cookie (`SameSite=None`)
2. Provider redirects with POST → Auth cookie sent (cross-site OK)
3. OAuth `/callback` → State validated and cleared
4. Success → User stored in main session, auth cookie deleted
"""
@behaviour Plug
import Plug.Conn
@default_options [
key: "_keen_auth",
signing_salt: "keen_auth_signing",
encryption_salt: "keen_auth_encrypt",
same_site: "None",
secure: true,
http_only: true,
max_age: 300
]
@impl true
def init(opts) do
Keyword.merge(@default_options, opts)
end
@impl true
def call(conn, opts) do
conn
|> fetch_cookies()
|> fetch_auth_cookie(opts)
|> put_private(:keen_auth_session_opts, opts)
|> register_before_send(&persist_auth_cookie/1)
end
defp fetch_auth_cookie(conn, opts) do
key = Keyword.fetch!(opts, :key)
signing_salt = Keyword.fetch!(opts, :signing_salt)
encryption_salt = Keyword.fetch!(opts, :encryption_salt)
secret = get_secret(conn)
data =
case conn.cookies[key] do
nil ->
%{}
cookie_value ->
case decrypt_cookie(cookie_value, secret, signing_salt, encryption_salt) do
{:ok, data} -> data
:error -> %{}
end
end
put_private(conn, :keen_auth_session, data)
end
defp persist_auth_cookie(conn) do
case conn.private[:keen_auth_session_changed] do
true ->
write_auth_cookie(conn)
_ ->
conn
end
end
defp write_auth_cookie(conn) do
opts = conn.private[:keen_auth_session_opts]
data = conn.private[:keen_auth_session] || %{}
key = Keyword.fetch!(opts, :key)
signing_salt = Keyword.fetch!(opts, :signing_salt)
encryption_salt = Keyword.fetch!(opts, :encryption_salt)
secret = get_secret(conn)
cookie_opts = [
http_only: Keyword.get(opts, :http_only, true),
secure: Keyword.get(opts, :secure, true),
same_site: Keyword.get(opts, :same_site, "None"),
max_age: Keyword.get(opts, :max_age, 300),
path: "/"
]
if map_size(data) == 0 do
delete_resp_cookie(conn, key, cookie_opts)
else
encrypted = encrypt_cookie(data, secret, signing_salt, encryption_salt)
put_resp_cookie(conn, key, encrypted, cookie_opts)
end
end
defp get_secret(conn) do
conn.secret_key_base ||
raise "secret_key_base not set on conn - ensure Plug.Session is configured"
end
defp encrypt_cookie(data, secret, signing_salt, encryption_salt) do
secret_key = Plug.Crypto.KeyGenerator.generate(secret, encryption_salt, iterations: 1000, length: 32)
sign_key = Plug.Crypto.KeyGenerator.generate(secret, signing_salt, iterations: 1000, length: 32)
data
|> :erlang.term_to_binary()
|> Plug.Crypto.MessageEncryptor.encrypt(secret_key, sign_key)
end
defp decrypt_cookie(cookie, secret, signing_salt, encryption_salt) do
secret_key = Plug.Crypto.KeyGenerator.generate(secret, encryption_salt, iterations: 1000, length: 32)
sign_key = Plug.Crypto.KeyGenerator.generate(secret, signing_salt, iterations: 1000, length: 32)
case Plug.Crypto.MessageEncryptor.decrypt(cookie, secret_key, sign_key) do
{:ok, binary} ->
{:ok, Plug.Crypto.non_executable_binary_to_term(binary)}
:error ->
:error
end
end
# ============================================================================
# Public API - called by KeenAuth controllers
# ============================================================================
@doc """
Get a value from the auth session.
"""
@spec get(Plug.Conn.t(), atom()) :: any()
def get(conn, key) do
data = conn.private[:keen_auth_session] || %{}
Map.get(data, key)
end
@doc """
Put a value in the auth session.
"""
@spec put(Plug.Conn.t(), atom(), any()) :: Plug.Conn.t()
def put(conn, key, value) do
data = conn.private[:keen_auth_session] || %{}
new_data = Map.put(data, key, value)
conn
|> put_private(:keen_auth_session, new_data)
|> put_private(:keen_auth_session_changed, true)
end
@doc """
Delete a value from the auth session.
"""
@spec delete(Plug.Conn.t(), atom()) :: Plug.Conn.t()
def delete(conn, key) do
data = conn.private[:keen_auth_session] || %{}
new_data = Map.delete(data, key)
conn
|> put_private(:keen_auth_session, new_data)
|> put_private(:keen_auth_session_changed, true)
end
@doc """
Get a value from auth session and delete it atomically.
"""
@spec get_and_delete(Plug.Conn.t(), atom()) :: {Plug.Conn.t(), any()}
def get_and_delete(conn, key) do
value = get(conn, key)
conn = delete(conn, key)
{conn, value}
end
@doc """
Clear the entire auth session.
"""
@spec clear(Plug.Conn.t()) :: Plug.Conn.t()
def clear(conn) do
conn
|> put_private(:keen_auth_session, %{})
|> put_private(:keen_auth_session_changed, true)
end
@doc """
Clear auth session and regenerate the main session ID.
Call this after successful authentication to:
1. Clear OAuth flow data from auth cookie
2. Regenerate main session ID (prevents session fixation)
"""
@spec clear_and_regenerate(Plug.Conn.t()) :: Plug.Conn.t()
def clear_and_regenerate(conn) do
conn
|> clear()
|> configure_session(renew: true)
end
@doc """
Regenerate main session ID without clearing auth data.
"""
@spec regenerate(Plug.Conn.t()) :: Plug.Conn.t()
def regenerate(conn) do
configure_session(conn, renew: true)
end
end