Current section
Files
Jump to
Current section
Files
lib/mix/tasks/twitch_auth.ex
defmodule Mix.Tasks.Twitch.Auth do
@moduledoc """
A task for getting an OAuth access token from Twitch, using the authorization
code flow.
NOTE: To use this, you need to add the local listener to your app's redirect urls:
http://localhost:42069/oauth/callback
Where `42069` is the default `--listen-port` value. Change the port to match any
value you use for this if you specify it yourself.
## Usage
mix twitch.auth [OPTION]
## Options
* `--output` - Specify how we want to output the token.
Valid values are: `json`, `.env`, `.envrc`, `stdout`, `clipboard`.
NOTE: Only `json` and `stdout` are supported currently.
* `--listen-port` - The port that the temporary web server will listen on.
Defaults to `42069` if not set.
* `--listen-timeout` - The time in ms that the server waits for a the twitch
redirect with the authorization code. Defaults to `300_000` if not set.
## Auth Options
* `--client-id` - The Twitch client ID for your app. Defaults to the value
of the `TWITCH_CLIENT_ID` env var if not set.
* `--client-secret` - The Twitch client secret for your app. Defaults to the
value of the `TWITCH_CLIENT_SECRET` env var if not set.
* `--auth-scope` - The Twitch auth scope for your app. A space-separated
string. Defaults to the value of the `TWITCH_AUTH_SCOPE` env var if not set.
If the env var is not set, it defaults to the `default_scope` list, which
should be all `read` scopes except for `whisper` and `stream_key`.
## 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.
"""
use Mix.Task
@base_url "https://id.twitch.tv/oauth2/authorize"
@outputs ~w[clipboard .env .envrc json stdout]
@default_output "json"
@default_listen_port 42069
@default_listen_timeout 300_000
@shortdoc "Gets a Twitch access token"
@doc false
@impl true
def run(argv) do
Logger.configure(level: :error)
## Parse args and options.
{opts, [], []} = OptionParser.parse(argv, switches: [output: :string])
output = opts[:output] || @default_output
if output not in @outputs, do: raise("output #{output} not one of #{inspect(@outputs)}")
client_id = opts[:client_id] || System.fetch_env!("TWITCH_CLIENT_ID")
client_secret = opts[:client_secret] || System.fetch_env!("TWITCH_CLIENT_SECRET")
scope = opts[:auth_scope] || System.fetch_env!("TWITCH_AUTH_SCOPE")
port = opts[:listen_port] || @default_listen_port
timeout = opts[:listen_timeout] || @default_listen_timeout
redirect_url = "http://localhost:#{port}/oauth/callback"
auth = TwitchAPI.Auth.new(client_id, client_secret)
## Start services.
{:ok, _http_client} = Application.ensure_all_started(:req)
{:ok, _webserver} =
Bandit.start_link(
plug: {TwitchAPI.AuthWebServer, process: self()},
port: port
)
## Open browser.
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", String.replace(url, "&", "^&")])
end
## Wait for the code result, and then fetch the token, and write it to output.
with(
{:ok, code} <- wait_for_code(timeout),
{:ok, token} <- fetch_token(auth, code, redirect_url),
:ok <- token_output(output, token)
) do
Mix.shell().info("Finished successfully")
else
{:error, reason} ->
Mix.shell().error(reason)
end
end
## Helpers
defp wait_for_code(timeout) do
receive do
{:code, code} -> {:ok, code}
{:failed, reason} -> {:error, reason}
_ -> wait_for_code(timeout)
after
timeout ->
{:error, "No response after #{div(timeout, 1000)} seconds"}
end
end
defp 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}
{:ok, %{body: body}} ->
{:error, "Token fetch error:\n#{inspect(body, pretty: true)}"}
{_, error} ->
{:error, "Token fetch error:\n#{inspect(error, pretty: true)}"}
end
end
## Write the token output
defp token_output("json", token) do
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)
Mix.shell().info("Wrote .twitch.json file")
end
defp token_output("stdout", token) do
Mix.shell().info("""
Access token received:
TWITCH_ACCESS_TOKEN=#{token["access_token"]}
TWITCH_REFRESH_TOKEN=#{token["refresh_token"]}
""")
end
defp token_output(output, _token) do
{:error, "unhandled output type #{output}"}
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 `AuthServer` to handle
# the rest.
defmodule TwitchAPI.AuthWebServer do
@moduledoc false
@behaviour Plug
alias Plug.Conn
require Logger
@impl true
def init(opts), do: Keyword.fetch!(opts, :process)
@impl true
def call(conn, process) do
if conn.path_info == ["oauth", "callback"] do
conn
|> Conn.fetch_query_params()
|> tap(&handle_code(&1.query_params, process))
|> Conn.send_resp(200, "You can close this window")
|> Conn.halt()
else
conn
|> Conn.send_resp(404, "Not found")
|> Conn.halt()
end
end
defp handle_code(%{"code" => code}, process) do
send(process, {:code, code})
end
defp handle_code(%{"error" => error, "error_detail" => detail}, process) do
send(process, {:failed, "error #{error}: #{detail}"})
end
end