Sending somebody to a Google consent screen, and turning what comes back into
a Sheetshow.UserAccount you can keep.
Three of the four steps are pure, and the fourth is one request:
account = Sheetshow.UserAccount.new(client_id, client_secret)
verifier = Sheetshow.OAuth.verifier()
state = Sheetshow.OAuth.state()
url = Sheetshow.OAuth.consent_url(account,
redirect_uri: "http://127.0.0.1:8910",
verifier: verifier,
state: state)
# open `url`, let them sign in, catch the redirect: all yours
{:ok, code} = Sheetshow.OAuth.code(redirect_url, state)
{:ok, account} = Sheetshow.OAuth.authorize(code, account,
redirect_uri: "http://127.0.0.1:8910", verifier: verifier)
File.write!(path, Sheetshow.UserAccount.to_json(account))What is not here is the browser and the listener. Opening a browser and
running an HTTP server to catch the redirect are a runtime, and runtimes are
the application's, not the library's. For a command-line tool the shortest
honest version is to print the URL, let the person paste the address they land
on back in, and hand that to code/2.
The two things worth knowing
access_type: "offline" and prompt: "consent" are the defaults, because
without them Google gives you a refresh token the first time somebody
approves your app and never again, so the second person to run your setup
gets an access token that dies in an hour and no way to get another. Asking
every time costs a click and removes a whole class of confused bug report.
PKCE is on by default. Pass the :verifier from verifier/0 to both
consent_url/2 and authorize/3; it never leaves your machine, and it is
what stops a code intercepted on its way back from being worth anything.
Two of Google's rules that will bite you rather than us
A refresh token issued while the app's publishing status is "Testing"
stops working seven days after consent. Nothing here can tell the
difference between that and a revoked grant, since both arrive as
%Sheetshow.Error{reason: :auth} saying the token was expired or revoked, so
if a credential dies a week after it was made, that is why, and publishing the
app is the cure.
And the out-of-band flow is gone: urn:ietf:wg:oauth:2.0:oob is refused.
A desktop app redirects to loopback (http://127.0.0.1:<port>), and a desktop
client needs no redirect URI registered, because Google accepts loopback on
whatever port you pick. Letting that redirect fail to connect and reading the
code out of the address bar is not the OOB flow: it is the loopback flow with
the listener left as an exercise.
Summary
Functions
Trades the code for a refresh token, and gives back the same account with it.
Same as authorize/3, raising on failure.
The challenge that goes in the consent URL: the verifier's SHA-256, which is why the verifier itself never travels.
The code out of the address the browser landed on, having checked that the
state is the one you sent.
The URL to send somebody to.
The exchange request as data, where to post and what to post, for whichever
HTTP client is doing the talking. Takes authorize/3's options.
Something random to send as state and compare when it comes back, so an
answer to somebody else's request is not mistaken for an answer to yours.
A PKCE code verifier: 86 characters of randomness to keep until authorize/3.
Functions
@spec authorize(String.t(), Sheetshow.UserAccount.t(), keyword()) :: {:ok, Sheetshow.UserAccount.t()} | {:error, Sheetshow.Error.t()}
Trades the code for a refresh token, and gives back the same account with it.
Options: :redirect_uri (required, and the same one consent_url/2 was given),
:verifier (the same one too, if you used PKCE) and :http, which passes
:timeout, :connect_timeout and :ssl through as Sheetshow.Client does.
The access token that comes back with it is deliberately dropped. Authorizing
is a setup step somebody runs once, and the token would be an hour from dead
before the program that needs it starts; Sheetshow.connect/1 gets a fresh one
from the refresh token in a single request.
{:ok, account} = Sheetshow.OAuth.authorize(code, account, redirect_uri: uri, verifier: verifier)
File.write!(path, Sheetshow.UserAccount.to_json(account)){:error, %Sheetshow.Error{reason: :auth}} when Google refuses: a code used
twice, a redirect URI that does not match the one the code was made for, or a
verifier that does not go with the challenge.
@spec authorize!(String.t(), Sheetshow.UserAccount.t(), keyword()) :: Sheetshow.UserAccount.t()
Same as authorize/3, raising on failure.
The challenge that goes in the consent URL: the verifier's SHA-256, which is why the verifier itself never travels.
iex> Sheetshow.OAuth.challenge("abc") |> byte_size()
43
@spec code(String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, Sheetshow.Error.t()}
The code out of the address the browser landed on, having checked that the
state is the one you sent.
A refusal at the consent screen comes back the same way, as a parameter rather
than an error status, and is {:error, %Sheetshow.Error{reason: :auth}} here.
iex> Sheetshow.OAuth.code("http://127.0.0.1:8910/?code=4/abc&state=xyz", "xyz")
{:ok, "4/abc"}
iex> {:error, %Sheetshow.Error{reason: :state_mismatch}} =
...> Sheetshow.OAuth.code("http://127.0.0.1:8910/?code=4/abc&state=other", "xyz")
iex> {:error, %Sheetshow.Error{reason: :auth}} =
...> Sheetshow.OAuth.code("http://127.0.0.1:8910/?error=access_denied", "xyz")
@spec consent_url(Sheetshow.UserAccount.t(), keyword()) :: String.t()
The URL to send somebody to.
:redirect_uri is required and must be one the app is registered with.
Options: :scopes (default Sheetshow.Google.default_scopes/0), :state, :verifier (PKCE,
strongly recommended), :access_type (default "offline"), :prompt
(default "consent") and :login_hint.
iex> account = Sheetshow.UserAccount.new("123.apps.googleusercontent.com", "s")
iex> url = Sheetshow.OAuth.consent_url(account, redirect_uri: "http://127.0.0.1:8910")
iex> %{query: query} = URI.parse(url)
iex> params = URI.decode_query(query)
iex> {params["client_id"], params["access_type"], params["response_type"]}
{"123.apps.googleusercontent.com", "offline", "code"}Raises ArgumentError without a redirect URI, because there is nowhere for
the answer to go.
@spec exchange_request(String.t(), Sheetshow.UserAccount.t(), keyword()) :: %{ url: String.t(), form: map() }
The exchange request as data, where to post and what to post, for whichever
HTTP client is doing the talking. Takes authorize/3's options.
Sheetshow.OAuth.exchange_request(code, account, redirect_uri: uri, verifier: verifier)
%{url: "https://oauth2.googleapis.com/token", form: %{grant_type: "authorization_code", ...}}
@spec state() :: String.t()
Something random to send as state and compare when it comes back, so an
answer to somebody else's request is not mistaken for an answer to yours.
iex> Sheetshow.OAuth.state() |> byte_size()
43
@spec verifier() :: String.t()
A PKCE code verifier: 86 characters of randomness to keep until authorize/3.
iex> Sheetshow.OAuth.verifier() |> byte_size()
86