Current section
Files
Jump to
Current section
Files
lib/slink/oauth.ex
defmodule Slink.OAuth do
@moduledoc """
The OAuth install flow for a multi-workspace ("Add to Slack") app.
Installing an app into a workspace is a standard OAuth v2 dance:
1. Send the installer to Slack's consent screen — `authorize_url/1`.
2. Slack redirects back to your app with a short-lived `code`.
3. Exchange the code for that workspace's bot token — `exchange/2` (or let
`Slink.OAuth.Plug` handle steps 2–3 as a mounted endpoint).
4. Persist `{team_id, bot_token}` in your own store, and hand it back per
request via the transports' `:bot_token` resolver (see *Multiple
workspaces* in `Slink.EventsApi.Plug`).
Slink does the Slack round-trips; the persistence is deliberately yours.
## Example
# In a controller / LiveView: the "Add to Slack" link.
Slink.OAuth.authorize_url(
client_id: client_id,
scopes: ~w(app_mentions:read chat:write commands),
redirect_uri: "https://example.com/slack/oauth/callback",
state: my_csrf_token
)
# In the callback route (or mount Slink.OAuth.Plug instead):
{:ok, install} = Slink.OAuth.exchange(code,
client_id: client_id,
client_secret: client_secret,
redirect_uri: "https://example.com/slack/oauth/callback"
)
MyApp.Installs.put(install.team_id, install.bot_token)
"""
defmodule Install do
@moduledoc """
A completed workspace installation, normalised from `oauth.v2.access`.
`raw` is Slack's full response for anything not surfaced (incoming
webhooks, the user token, granted scopes, …).
"""
# The bot token is a secret — keep it out of inspects and crash reports,
# like Slink.Context does.
@derive {Inspect, except: [:bot_token, :raw]}
defstruct [
:team_id,
:team_name,
:enterprise_id,
:bot_token,
:bot_user_id,
:app_id,
:authed_user_id,
:raw
]
@type t :: %__MODULE__{
team_id: String.t() | nil,
team_name: String.t() | nil,
enterprise_id: String.t() | nil,
bot_token: String.t() | nil,
bot_user_id: String.t() | nil,
app_id: String.t() | nil,
authed_user_id: String.t() | nil,
raw: map()
}
end
@authorize_url "https://slack.com/oauth/v2/authorize"
@doc """
The Slack consent-screen URL an installer should be sent to.
Options:
* `:client_id` (required) — the app's Client ID (Basic Information page).
* `:scopes` (required) — bot scopes to request: a list or comma-string.
* `:redirect_uri` — where Slack sends the installer back to (must be one of
the app's configured Redirect URLs; optional when exactly one is
configured there).
* `:state` — an opaque value echoed back on the redirect. Use a value tied
to the installer's session and check it in the callback (see
`Slink.OAuth.Plug`'s `:verify_state`) to prevent forged installs.
* `:user_scopes` — user-token scopes, if the app requests any.
* `:team` — a workspace id to pre-select on the consent screen.
"""
def authorize_url(opts) do
query =
[
client_id: Keyword.fetch!(opts, :client_id),
scope: scopes(Keyword.fetch!(opts, :scopes)),
redirect_uri: opts[:redirect_uri],
state: opts[:state],
user_scope: opts[:user_scopes] && scopes(opts[:user_scopes]),
team: opts[:team]
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> URI.encode_query()
@authorize_url <> "?" <> query
end
defp scopes([]), do: nil
defp scopes(scopes) when is_list(scopes), do: Enum.join(scopes, ",")
defp scopes(scopes) when is_binary(scopes), do: scopes
@doc """
Exchange the redirect's `code` for the workspace's tokens.
Calls `oauth.v2.access` with the app's client credentials and returns
`{:ok, %Slink.OAuth.Install{}}`, or `{:error, reason}` — Slack's error string
(e.g. `"invalid_code"` for an expired/reused code) or a transport error.
Options: `:client_id` and `:client_secret` (required; the secret may be a
0-arity function, resolved here), and `:redirect_uri` (required if the
authorize URL carried one).
"""
def exchange(code, opts) do
client_id = Keyword.fetch!(opts, :client_id)
client_secret = resolve(Keyword.fetch!(opts, :client_secret))
case Slink.API.oauth_access(client_id, client_secret, code, opts[:redirect_uri]) do
{:ok, body} -> {:ok, install(body)}
{:error, reason} -> {:error, reason}
end
end
defp resolve(fun) when is_function(fun, 0), do: fun.()
defp resolve(value), do: value
# Normalise the oauth.v2.access body. Lookups are nil-safe (Slack sends
# `"enterprise": null` outside Enterprise Grid).
defp install(body) do
%Install{
team_id: get_in(body, ["team", "id"]),
team_name: get_in(body, ["team", "name"]),
enterprise_id: get_in(body, ["enterprise", "id"]),
bot_token: body["access_token"],
bot_user_id: body["bot_user_id"],
app_id: body["app_id"],
authed_user_id: get_in(body, ["authed_user", "id"]),
raw: body
}
end
end