Sheetshow.OAuth (Sheetshow v0.1.0)

Copy Markdown View Source

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

authorize(code, account, opts)

@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.

authorize!(code, account, opts)

Same as authorize/3, raising on failure.

challenge(verifier)

@spec challenge(String.t()) :: String.t()

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

code(redirect_url, state \\ nil)

@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")

exchange_request(code, account, opts)

@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", ...}}

state()

@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

verifier()

@spec verifier() :: String.t()

A PKCE code verifier: 86 characters of randomness to keep until authorize/3.

iex> Sheetshow.OAuth.verifier() |> byte_size()
86