defmodule Ueberauth.Strategy.WorkOS do
@moduledoc """
Implementation of an Ueberauth Strategy for WorkOS Single Sign-On
## Configuration
This provider supports the following configuration:
* `api_key`: (**Required**) WorkOS API key, which also acts as the OAuth client secret. This key
is environment-specific and may be supplied using runtime configuration.
* `client_id`: (**Required**) OAuth client ID obtained from WorkOS. This ID is
environment-specific and may be supplied using runtime configuration.
* `callback_url`: Redirect URI to send users for the callback phase. This URL
must be allowed in the WorkOS configuration for the environment matching the Client ID.
Defaults to a callback URL calculated using the endpoint host and provider name.
Example configuration:
config :ueberauth, Ueberauth,
providers: [
workos: {Ueberauth.Strategy.WorkOS, [
api_key: System.fetch_env!("WORKOS_API_KEY"),
client_id: System.fetch_env!("WORKOS_CLIENT_ID")
]}
]
Alternatively, you may configure the strategy module directly:
config :ueberauth, Ueberauth.Strategy.WorkOS,
api_key: System.fetch_env!("WORKOS_API_KEY"),
client_id: System.fetch_env!("WORKOS_CLIENT_ID")
## Connection Selector
In addition to the configuration mentioned above, the request phase also accepts several params
allowing the client to specify details of the login process. One of these is the **Connection
Selector**. The WorkOS documentation states:
> To indicate the connection to use for authentication, use one of the following connection
> selectors: connection, organization, or provider.
>
> These connection selectors are mutually exclusive, and exactly one must be provided.
Therefore, the request phase must include exactly one of `connection`, `organization`, or
`provider` in the incoming params. These may be provided directly by the client, or inserted
before Ueberauth runs (before `plug Ueberauth`) by a custom plug. If absent, the request will
fail immediately.
## Additional Params
WorkOS also provides the ability to give "hints" about the domain or login. These hints may also
be provided by the client or another plug using connection params:
* `domain_hint`: According to WorkOS: _Can be used to pre-fill the domain field when initiating
authentication with Microsoft OAuth, or with a `GoogleSAML` connection type._
* `login_hint`: According to WorkOS: _Can be used to pre-fill the username/email address field
of the IdP sign-in page for the user, if you know their username ahead of time._
If you use an email address to determine the connection selector, then it is advisable to use the
same email address as the `login_hint`.
"""
use Ueberauth.Strategy,
ignores_csrf_attack: true
alias Ueberauth.Auth.Credentials
alias Ueberauth.Auth.Extra
alias Ueberauth.Auth.Info
alias Ueberauth.Strategy.WorkOS.OAuth
#
# Plug Callbacks
#
@doc false
@impl Ueberauth.Strategy
def handle_request!(conn) do
params =
[]
|> with_connection_selector(conn)
|> with_param(:domain_hint, conn)
|> with_param(:login_hint, conn)
|> with_state_param(conn)
opts = oauth_client_options_from_conn(conn)
redirect!(conn, OAuth.authorize_url!(params, opts))
end
@doc false
@impl Ueberauth.Strategy
def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do
params = [code: code]
conn
|> oauth_client_options_from_conn()
|> OAuth.client()
|> OAuth2.Client.get_token(params)
|> case do
{:ok,
%OAuth2.Client{token: %OAuth2.AccessToken{other_params: %{"profile" => profile}} = token}} ->
conn
|> put_private(:workos_profile, profile)
|> put_private(:workos_token, token)
{:error, %OAuth2.Response{body: %{"error" => error, "error_description" => description}}} ->
{:error, {error, description}}
{:error, %OAuth2.Error{reason: reason}} ->
{:error, {"error", to_string(reason)}}
end
end
def handle_callback!(%Plug.Conn{params: %{"error" => error}} = conn) do
set_errors!(conn, [error("auth_failed", error)])
end
def handle_callback!(conn) do
set_errors!(conn, [error("missing_code", "No code received")])
end
@doc false
@impl Ueberauth.Strategy
def handle_cleanup!(conn) do
conn
|> put_private(:workos_profile, nil)
|> put_private(:workos_token, nil)
end
#
# Data Processing Callbacks
#
@doc false
@impl Ueberauth.Strategy
def uid(conn), do: conn.private[:workos_profile]["id"]
@doc false
@impl Ueberauth.Strategy
def credentials(conn) do
expiration = DateTime.utc_now() |> DateTime.add(10 * 60, :second) |> DateTime.to_unix()
%Credentials{
expires: true,
expires_at: expiration,
token: conn.private[:workos_token].access_token,
token_type: "access_token"
}
end
@doc false
@impl Ueberauth.Strategy
def extra(conn) do
%Extra{
raw_info: conn.private[:workos_profile]
}
end
@doc false
@impl Ueberauth.Strategy
def info(conn) do
%Info{
email: conn.private[:workos_profile]["email"],
first_name: conn.private[:workos_profile]["first_name"],
last_name: conn.private[:workos_profile]["last_name"]
}
end
#
# Helpers
#
@spec with_connection_selector(keyword, Plug.Conn.t()) :: keyword
defp with_connection_selector(params, conn) do
case conn.params do
%{"connection" => connection_id} -> Keyword.put(params, :connection, connection_id)
%{"organization" => org_id} -> Keyword.put(params, :organization, org_id)
%{"provider" => provider} -> Keyword.put(params, :provider, provider)
_else -> raise "Missing WorkOS connection, organization, or provider"
end
end
@spec with_param(keyword, atom, Plug.Conn.t()) :: keyword
defp with_param(params, key, conn) do
if value = conn.params[to_string(key)], do: Keyword.put(params, key, value), else: params
end
@spec oauth_client_options_from_conn(Plug.Conn.t()) :: keyword
defp oauth_client_options_from_conn(conn) do
base_options = [redirect_uri: callback_url(conn)]
request_options = conn.private[:ueberauth_request_options].options
case {request_options[:client_id], request_options[:api_key]} do
{nil, _} -> base_options
{_, nil} -> base_options
{id, secret} -> [client_id: id, api_key: secret] ++ base_options
end
end
end