Packages
mob_dev
0.6.20
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/google_play/oauth.ex
defmodule MobDev.GooglePlay.OAuth do
@moduledoc """
OAuth2 browser-based authorization for Google APIs.
Opens the user's default browser to Google's consent screen, then listens
on a random localhost port for the authorization code callback. No external
CLI tool (gcloud, etc.) is required — only a browser.
## OAuth client registration
This module ships with placeholder client credentials. Before `authorize/1`
will work you must register a Google OAuth "Desktop app" client:
1. Go to https://console.cloud.google.com/apis/credentials
2. Click **Create credentials → OAuth client ID**
3. Application type: **Desktop app** — name it `mob_dev CLI`
4. Click **Create** — note the **Client ID** and **Client secret**
5. Fill in `@default_client_id` and `@default_client_secret` in this file
For installed CLI tools, the client_secret is not actually secret — this
follows Google's documented guidance for desktop applications (the same
model used by the gcloud CLI). The token is only obtainable by a user who
explicitly grants consent via their browser.
You can also override with environment variables:
`GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET`.
"""
alias MobDev.GooglePlay.HTTP
# TODO: register at https://console.cloud.google.com/apis/credentials
# Application type: Desktop app. Fill in the values below after registration.
@default_client_id "TODO_REGISTER.apps.googleusercontent.com"
@default_client_secret "TODO_REGISTER_SECRET"
@auth_url "https://accounts.google.com/o/oauth2/v2/auth"
@token_url "https://oauth2.googleapis.com/token"
# Scopes for the setup wizard:
# cloud-platform → enable APIs, create service accounts and keys (GCP APIs)
# androidpublisher → grant Play Console access to the service account
@setup_scopes [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/androidpublisher"
]
@doc """
Returns the scopes requested during the setup wizard OAuth flow.
"""
@spec setup_scopes() :: [String.t()]
def setup_scopes, do: @setup_scopes
@doc """
Runs the browser-based OAuth2 flow and returns a bearer access token.
Opens the user's browser to the Google consent screen, then waits up to
`timeout_ms` milliseconds (default 120 000) for the callback redirect.
Options:
- `:scopes` — list of OAuth scope strings (required)
- `:timeout_ms` — callback wait timeout in ms (default: 120_000)
Returns `{:ok, access_token}` or `{:error, reason}`.
"""
@spec authorize(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def authorize(opts \\ []) do
scopes = Keyword.fetch!(opts, :scopes)
timeout_ms = Keyword.get(opts, :timeout_ms, 120_000)
client_id = System.get_env("GOOGLE_OAUTH_CLIENT_ID", @default_client_id)
client_secret = System.get_env("GOOGLE_OAUTH_CLIENT_SECRET", @default_client_secret)
if String.starts_with?(client_id, "TODO") do
{:error,
"OAuth client not registered yet. " <>
"See MobDev.GooglePlay.OAuth moduledoc for registration steps."}
else
HTTP.ensure_started!()
with {:ok, port} <- find_free_port(),
redirect_uri = "http://localhost:#{port}/callback",
url = build_auth_url(client_id, scopes, redirect_uri),
:ok <- open_browser(url),
{:ok, code} <- await_callback(port, timeout_ms),
{:ok, tokens} <- exchange_code(client_id, client_secret, code, redirect_uri) do
{:ok, tokens["access_token"]}
end
end
end
@doc """
Builds the Google OAuth2 authorization URL.
Pure function — useful for testing and for displaying the URL in case
the automatic browser open fails.
"""
@spec build_auth_url(String.t(), [String.t()], String.t()) :: String.t()
def build_auth_url(client_id, scopes, redirect_uri) do
params = %{
"client_id" => client_id,
"redirect_uri" => redirect_uri,
"response_type" => "code",
"scope" => Enum.join(scopes, " "),
"access_type" => "offline",
"prompt" => "consent"
}
"#{@auth_url}?#{URI.encode_query(params)}"
end
@doc """
Parses the authorization code from an OAuth callback HTTP request line.
The request line has the form:
GET /callback?code=AUTH_CODE&scope=... HTTP/1.1
Returns `{:ok, code}` or `{:error, reason}`.
"""
@spec parse_callback_request(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def parse_callback_request(request_line) do
case Regex.run(Regex.compile!(~S{(?:GET|HEAD) /[^?]*\?([^ ]+)}), request_line) do
[_, query] ->
params = URI.decode_query(query)
case {params["code"], params["error"]} do
{code, nil} when is_binary(code) and code != "" ->
{:ok, code}
{_, error} when is_binary(error) ->
{:error, "Google denied access: #{error}"}
_ ->
{:error, "No code or error in callback query: #{query}"}
end
_ ->
{:error, "Unexpected callback request: #{String.slice(request_line, 0, 100)}"}
end
end
# ── Internals ────────────────────────────────────────────────────────────────
defp find_free_port do
case :gen_tcp.listen(0, [:binary, active: false]) do
{:ok, sock} ->
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
{:ok, port}
{:error, reason} ->
{:error, "Could not find a free port: #{inspect(reason)}"}
end
end
defp open_browser(url) do
cmd =
case :os.type() do
{:unix, :darwin} -> {"open", [url]}
{:unix, _} -> {"xdg-open", [url]}
{:win32, _} -> {"cmd", ["/c", "start", url]}
end
Mix.shell().info(" Opening browser to Google sign-in...")
Mix.shell().info(" If it doesn't open automatically, visit:")
Mix.shell().info(" #{url}")
Mix.shell().info("")
{bin, args} = cmd
case System.find_executable(bin) do
nil -> :ok
_ -> System.cmd(bin, args, stderr_to_stdout: true)
end
:ok
end
defp await_callback(port, timeout_ms) do
case :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true]) do
{:ok, server} ->
Mix.shell().info(" Waiting for browser sign-in (#{div(timeout_ms, 1000)}s timeout)...")
result =
case :gen_tcp.accept(server, timeout_ms) do
{:ok, conn} ->
handle_callback_connection(conn)
{:error, :timeout} ->
{:error, "Timed out waiting for OAuth callback — did the browser open?"}
{:error, reason} ->
{:error, "Callback listener error: #{inspect(reason)}"}
end
:gen_tcp.close(server)
result
{:error, reason} ->
{:error, "Could not start callback listener on port #{port}: #{inspect(reason)}"}
end
end
defp handle_callback_connection(conn) do
result =
case :gen_tcp.recv(conn, 0, 10_000) do
{:ok, request_line} -> parse_callback_request(request_line)
{:error, reason} -> {:error, "Could not read callback request: #{inspect(reason)}"}
end
html = callback_html(result)
response =
"HTTP/1.1 200 OK\r\n" <>
"Content-Type: text/html\r\n" <>
"Content-Length: #{byte_size(html)}\r\n" <>
"Connection: close\r\n\r\n" <>
html
:gen_tcp.send(conn, response)
:gen_tcp.close(conn)
result
end
defp callback_html({:ok, _}) do
"<html><body><h1>Authenticated</h1>" <>
"<p>Sign-in successful. You can close this tab and return to the terminal.</p>" <>
"</body></html>"
end
defp callback_html({:error, reason}) do
"<html><body><h1>Authentication failed</h1>" <>
"<p>#{html_escape(reason)}</p>" <>
"<p>Close this tab and check the terminal for details.</p>" <>
"</body></html>"
end
# Minimal HTML entity escaping — the error message comes from Google, not user input,
# but be safe.
defp html_escape(str) do
str
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
end
defp exchange_code(client_id, client_secret, code, redirect_uri) do
body =
URI.encode_query(%{
"code" => code,
"client_id" => client_id,
"client_secret" => client_secret,
"redirect_uri" => redirect_uri,
"grant_type" => "authorization_code"
})
headers = [{"content-type", "application/x-www-form-urlencoded"}]
case HTTP.post(@token_url, headers, body) do
{:ok, %{"access_token" => _} = tokens} ->
{:ok, tokens}
{:ok, resp} ->
{:error, "Token exchange returned unexpected response: #{inspect(resp)}"}
{:error, _} = err ->
err
end
end
end