Packages

An Ueberauth strategy for using EVE Online SSO to authenticate your users.

Current section

Files

Jump to
ueberauth_eve_online lib ueberauth strategy evesso.ex
Raw

lib/ueberauth/strategy/evesso.ex

defmodule Ueberauth.Strategy.EVESSO do
@moduledoc """
Provides an Ueberauth strategy for authenticating with EVE SSO v2.
### Setup
Create an SSO Application on the [EVE Developers page](https://developers.eveonline.com/).
After registering an application get the client id and secret key from the application details page.
Include the credentials in the configuration for EVESSO
config :ueberauth, Ueberauth.Strategy.EVESSO.OAuth,
client_id: System.get_env("EVESSO_CLIENT_ID"),
client_secret: System.get_env("EVESSO_SECRET_KEY")
If you haven't already, create a pipeline and set up routes for your callback handler
pipeline :auth do
Ueberauth.plug "/auth"
end
scope "/auth" do
pipe_through [:browser, :auth]
get "/:provider/callback", AuthController, :callback
end
Create an endpoint for the callback where you will handle the Ueberauth.Auth struct
defmodule MyApp.AuthController do
use MyApp.Web, :controller
def callback_phase(%{assigns: %{ueberauth_failure: fails}} = conn, _params) do
#do things with the failure
end
def callback_phase(%{assigns: %{ueberauth_auth: auth}} = conn, params) do
# do things with the auth
end
end
You can edit the behaviour of the Strategy by including some options when you register your provider
To set the `uid_field`
config :ueberauth, Ueberauth,
providers: [
evesso: {Ueberauth.Strategy.EVESSO, [uid_field: :character_id]}
]
Default is `:owner_hash`, others available are `:character_id` and `:name`
To set the default scopes:
config :ueberauth, Ueberauth,
providers: [
evesso: {Ueberauth.Strategy.EVESSO, [default_scope: "esi-clones.read_implants.v1 esi-characters.read_notifications.v1"]}
]
Default is empty ("") which doesn't grant any extra permissions beyond public endpoints but enables you to verify character ownership.
Scopes are provided as a space-separated list.
### HTTPS Configuration
If your application runs behind a proxy (nginx, load balancer) that terminates SSL, you may need to configure
the callback URL scheme to use HTTPS:
config :ueberauth, Ueberauth,
providers: [
evesso: {Ueberauth.Strategy.EVESSO, [callback_scheme: "https"]}
]
You can also set a specific callback URL:
config :ueberauth, Ueberauth,
providers: [
evesso: {Ueberauth.Strategy.EVESSO, [callback_url: "https://your-domain.com/auth/evesso/callback"]}
]
Or configure your web server to set the `X-Forwarded-Proto` header to `https` for proper scheme detection.
"""
use Ueberauth.Strategy,
uid_field: :owner_hash,
default_scope: "",
oauth2_module: Ueberauth.Strategy.EVESSO.OAuth
alias Ueberauth.Auth.{Info, Credentials, Extra}
@doc """
Handles the initial redirect to the EVE SSO authentication page
To customize the scopes that are requested from SSO include them as part of your url:
"/auth/evesso?scope=esi-clones.read_implants.v1"
EVE SSO v2 also requires a `state` param that will be returned and can be used to guard against MITM attacks.
"""
@spec handle_request!(Plug.Conn.t()) :: Plug.Conn.t()
def handle_request!(conn) do
scopes = conn.params["scope"] || option(conn, :default_scope)
send_redirect_uri = Keyword.get(options(conn), :send_redirect_uri, true)
# Apply callback configuration for HTTPS support
conn = apply_callback_config(conn)
opts =
if send_redirect_uri do
[redirect_uri: callback_url(conn), scope: scopes]
else
[scope: scopes]
end
opts = Keyword.put(opts, :state, conn.private[:ueberauth_state_param])
module = option(conn, :oauth2_module)
redirect!(conn, apply(module, :authorize_url!, [opts]))
end
@doc """
Handles the callback from EVE SSO. When there is a failure from EVE SSO the failure is included in the
`ueberauth_failure` struct. Otherwise the information returned in the token is returned in the Ueberauth.Auth struct.
"""
@spec handle_callback!(Plug.Conn.t()) :: Plug.Conn.t()
def handle_callback!(%Plug.Conn{params: %{"code" => code, "state" => state}} = conn) do
# Get expected state from cookie (fix for ueberauth state cookie bug)
expected_state = conn.private[:ueberauth_state_param] || Map.get(conn.req_cookies, "ueberauth.state_param")
if state != expected_state do
set_errors!(conn, [error("invalid_state", "Invalid state parameter. Possible CSRF attack.")])
else
exchange_code_for_token(conn, code)
end
end
@spec handle_callback!(Plug.Conn.t()) :: Plug.Conn.t()
def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do
# If no state is provided, log a warning but continue (for backward compatibility)
require Logger
Logger.warning(
"No state parameter provided in EVE SSO callback - consider enabling state validation for security"
)
exchange_code_for_token(conn, code)
end
@spec handle_callback!(Plug.Conn.t()) :: Plug.Conn.t()
def handle_callback!(conn) do
set_errors!(conn, [error("missing_code", "No code received")])
end
@doc """
Cleans up the private area of the connection used for passing the raw SSO response around during the callback phase
"""
@spec handle_cleanup!(Plug.Conn.t()) :: Plug.Conn.t()
def handle_cleanup!(conn) do
conn
|> put_private(:evesso_token, nil)
|> put_private(:evesso_user, nil)
end
@doc """
Fetches the uid field from the token payload. This defaults to the option `uid_field` which in turn defaults to `owner_hash`
"""
@spec uid(Plug.Conn.t()) :: any()
def uid(conn) do
conn |> option(:uid_field) |> fetch_uid(conn)
end
@doc """
Includes the credentials from the SSO response.
"""
@spec credentials(Plug.Conn.t()) :: Credentials.t()
def credentials(conn) do
token = conn.private.evesso_token
scope_string = token.other_params["scope"] || ""
scopes = String.split(scope_string, " ")
%Credentials{
token: token.access_token,
refresh_token: token.refresh_token,
expires_at: token.expires_at,
token_type: token.token_type,
expires: !!token.expires_at,
scopes: scopes
}
end
@doc """
Fetches the fields to populate the info section of the `Ueberauth.Auth` struct.
"""
@spec info(Plug.Conn.t()) :: Info.t()
def info(conn) do
user = conn.private.evesso_user
%Info{
name: user.name
}
end
@doc """
Stores the raw information, including the token, obtained from the SSO callback.
"""
@spec extra(Plug.Conn.t()) :: Extra.t()
def extra(conn) do
%Extra{
raw_info: %{
token: conn.private.evesso_token,
user: conn.private.evesso_user
}
}
end
defp fetch_uid(field, conn) do
conn.private.evesso_user[field]
end
defp fetch_user(conn, token) do
conn = put_private(conn, :evesso_token, token)
with {:ok, verified} <- Ueberauth.Strategy.EVESSO.OAuth.verify(token),
{:ok, user} <- extra_user_info(verified) do
put_private(conn, :evesso_user, user)
else
{:error, reason} ->
set_errors!(conn, error("Verify", reason))
_err ->
set_errors!(conn, error("Verify", "Unexpected error during verification"))
end
end
defp extra_user_info(verify) do
character_id =
verify["sub"]
|> String.replace("CHARACTER:EVE:", "")
|> String.to_integer()
user = %{
name: verify["name"],
character_id: character_id,
owner_hash: verify["owner"]
}
{:ok, user}
end
# Exchange authorization code for access token
defp exchange_code_for_token(conn, code) do
module = option(conn, :oauth2_module)
send_redirect_uri = Keyword.get(options(conn), :send_redirect_uri, true)
# Apply callback configuration for HTTPS support
conn = apply_callback_config(conn)
params = if send_redirect_uri do
[code: code, redirect_uri: callback_url(conn)]
else
[code: code]
end
try do
token = apply(module, :get_token!, [params])
if token.access_token == nil do
set_errors!(conn, [
error(token.other_params["error"], token.other_params["error_description"])
])
else
fetch_user(conn, token)
end
rescue
e in OAuth2.Error ->
# Handle OAuth2 errors gracefully
{error_code, error_description} = parse_oauth_error(e)
set_errors!(conn, [error(error_code, error_description)])
end
end
# Parse OAuth2.Error into user-friendly error messages
defp parse_oauth_error(%OAuth2.Error{reason: %{body: body}}) when is_binary(body) do
case Ueberauth.json_library().decode(body) do
{:ok, %{"error" => "invalid_grant", "error_description" => desc}} ->
{"invalid_grant", desc}
{:ok, %{"error" => "invalid_grant"}} ->
{"invalid_grant",
"Authorization code is invalid or expired. Please try logging in again."}
{:ok, %{"error" => error, "error_description" => desc}} ->
{error, desc}
{:ok, %{"error" => error}} ->
{error, "OAuth2 authentication failed"}
_ ->
{"oauth2_error", "Authentication failed"}
end
end
# Handle network-related errors
defp parse_oauth_error(%OAuth2.Error{reason: %{reason: :nxdomain}}) do
{"network_error", "Unable to connect to EVE SSO. Please check your internet connection."}
end
defp parse_oauth_error(%OAuth2.Error{reason: %{reason: :timeout}}) do
{"network_error", "EVE SSO request timed out. Please try again later."}
end
defp parse_oauth_error(%OAuth2.Error{reason: %{reason: :econnrefused}}) do
{"network_error", "Unable to connect to EVE SSO. Service may be temporarily unavailable."}
end
defp parse_oauth_error(%OAuth2.Error{reason: reason}) when is_binary(reason) do
{"oauth2_error", reason}
end
defp parse_oauth_error(_) do
{"oauth2_error", "OAuth2 authentication failed"}
end
defp option(conn, key) do
Keyword.get(options(conn), key, Keyword.get(default_options(), key))
end
# Apply callback configuration options to support HTTPS
defp apply_callback_config(conn) do
options = options(conn)
# Get existing request options - can be either a map (real Ueberauth) or keyword list (tests)
existing_opts = conn.private[:ueberauth_request_options]
# Handle different data structures from Ueberauth vs tests
existing_opts = case existing_opts do
nil -> %{} # Default to map for real usage
opts when is_list(opts) -> opts # Keep as keyword list for tests
opts when is_map(opts) -> opts # Keep as map for real usage
end
# Add callback configuration to request options
updated_opts = existing_opts
|> maybe_put_callback_url_opt(Keyword.get(options, :callback_url))
|> maybe_put_callback_scheme_opt(Keyword.get(options, :callback_scheme))
put_private(conn, :ueberauth_request_options, updated_opts)
end
defp maybe_put_callback_url_opt(opts, nil), do: opts
defp maybe_put_callback_url_opt(opts, callback_url) when is_map(opts) do
Map.put(opts, :callback_url, callback_url)
end
defp maybe_put_callback_url_opt(opts, callback_url) when is_list(opts) do
Keyword.put(opts, :callback_url, callback_url)
end
defp maybe_put_callback_scheme_opt(opts, nil), do: opts
defp maybe_put_callback_scheme_opt(opts, callback_scheme) when is_map(opts) do
Map.put(opts, :callback_scheme, callback_scheme)
end
defp maybe_put_callback_scheme_opt(opts, callback_scheme) when is_list(opts) do
Keyword.put(opts, :callback_scheme, callback_scheme)
end
end