Current section
Files
Jump to
Current section
Files
lib/mix/tasks/auth_token.ex
defmodule Mix.Tasks.Auth.Token do
@moduledoc """
A task for getting an OAuth access token from Twitch, using the authorization
code flow.
## Usage
mix auth.token --output json
## Options
* `--output` - Specify how we want to output the token.
Valid values are: `json`, `.env`, `.envrc`, `stdio`, `clipboard`.
NOTE: Only `json` is supported currently.
## ENV Vars
* `TWITCH_CLIENT_ID` - The Twitch client ID for your app.
* `TWITCH_CLIENT_SECRET` - The Twitch client secret for your app.
* `TWITCH_AUTH_SCOPE` - The Twitch scopes as a space-separated string.
* `TWITCH_AUTH_PORT` - The port that the temp web server will listen on.
Defaults to `42069`.
"""
use Mix.Task
@base_url "https://id.twitch.tv/oauth2/authorize"
@shortdoc "Gets an access token from Twitch and writes it to `:output`"
@impl true
def run(argv) do
# mix auth.token --output .env | .envrc | json | stdio | clipboard
{opts, [], []} = OptionParser.parse(argv, strict: [output: :string])
Application.ensure_all_started(:req)
# TODO: Check args first.
client_id = System.fetch_env!("TWITCH_CLIENT_ID")
client_secret = System.fetch_env!("TWITCH_CLIENT_SECRET")
scope = System.fetch_env!("TWITCH_AUTH_SCOPE")
port = System.get_env("TWITCH_AUTH_PORT", "42069") |> String.to_integer()
redirect_url = "http://localhost:#{port}/oauth/callback"
auth = TwitchAPI.Auth.new(client_id, client_secret)
{:ok, bandit_pid} = Bandit.start_link(plug: TwitchAPI.AuthWebServer, port: port)
params =
URI.encode_query(%{
client_id: client_id,
redirect_uri: redirect_url,
response_type: "code",
scope: scope
})
url = "#{@base_url}?#{params}"
case :os.type() do
{:unix, _} -> System.cmd("open", [url])
{:win32, _} -> System.cmd("cmd", ["/c", "start", url])
end
{:ok, _token_pid} =
TwitchAPI.AuthServer.start_link(
auth: auth,
process: self(),
output: opts[:output],
redirect_url: redirect_url
)
wait(bandit_pid)
end
## Helpers
defp wait(bandit_pid) do
receive do
:done ->
Mix.shell().info("Finished successfully")
Supervisor.stop(bandit_pid, :normal)
{:failed, reason} ->
Mix.shell().error(reason)
Supervisor.stop(bandit_pid, :normal)
_ ->
wait(bandit_pid)
end
end
end
# ------------------------------------------------------------------------------
# Auth Web Server
# ------------------------------------------------------------------------------
# We need a temporary web server to handle the OAuth callback with the
# authorization code. We then send that code to the `AuthTokenServer` to handle
# the rest.
defmodule TwitchAPI.AuthWebServer do
@moduledoc false
@behaviour Plug
import Plug.Conn
require Logger
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
if conn.path_info == ["oauth", "callback"] do
conn
|> fetch_query_params()
|> Map.fetch!(:query_params)
|> handle_code()
end
send_resp(conn, 200, "You can close this window.") |> halt()
end
defp handle_code(%{"code" => code} = _params) do
TwitchAPI.AuthServer.get_token_from_code(code)
end
defp handle_code(%{"error" => _error} = params) do
TwitchAPI.AuthServer.failed("error #{params["error"]}: #{params["error_detail"]}")
end
end
# ------------------------------------------------------------------------------
# Auth GenServer
# ------------------------------------------------------------------------------
# This is used for keeping the auth credentials and making the request to
# Twitch API. We need this as a genserver to be able to receive messages from
# the web server and then stopping the main process.
defmodule TwitchAPI.AuthServer do
@moduledoc false
use GenServer
require Logger
@default_output "json"
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def get_token_from_code(code) do
GenServer.cast(__MODULE__, {:token_from_code, code})
end
def failed(reason) do
GenServer.cast(__MODULE__, {:failed, reason})
end
## GenServer callbacks
@impl true
def init(opts) do
state = %{
auth: Keyword.fetch!(opts, :auth),
process: Keyword.fetch!(opts, :process),
redirect_url: Keyword.fetch!(opts, :redirect_url),
output: Keyword.get(opts, :output) || @default_output
}
{:ok, state}
end
@impl true
def handle_cast({:token_from_code, code}, state) do
with(
{:ok, token} <- fetch_token(state.auth, code, state.redirect_url),
:ok <- token_output(state.output, token)
) do
send(state.process, :done)
else
{:error, reason} ->
send(state.process, {:failed, reason})
end
{:stop, :normal, state}
end
@impl true
def handle_cast({:failed, reason}, state) do
send(state.process, {:failed, reason})
{:stop, :normal, state}
end
## Helpers
def fetch_token(auth, code, redirect_url) do
case TwitchAPI.Auth.token_get_from_code(auth, code, redirect_url) do
{:ok, %{status: 200, body: token}} ->
{:ok, token}
{_, error} ->
{:error, "failed to get token #{inspect(error, pretty: true)}"}
end
end
defp token_output("json", token) do
Logger.debug("[AuthTokenServer] writing json...")
expires_at = DateTime.utc_now() |> DateTime.add(token["expires_in"], :second)
json =
token
|> Map.put("expires_at", expires_at)
|> Map.delete("expires_in")
|> Jason.encode!(pretty: true)
File.write!(".twitch.json", json)
end
defp token_output(output, _token) do
{:error, "unhandled output type #{output}"}
end
end