Shelly.OAuth (Shelly v0.3.0)

Copy Markdown View Source

Shelly Cloud OAuth (authorization-code) flow — the "connect your account" path that replaces auth keys entirely.

Note: the flow is Shelly's own variant — the authorization code is itself a JWT whose (unverified) claims name the account's cloud server, and those claims are pinned to https://*.shelly.cloud before use. Shelly's authorization page does accept a state parameter and echoes it to your callback; pass one via authorize_url/2 and compare it before calling exchange_code/2 — it is the only CSRF binding this flow has.

Flow:

  1. Send the user to authorize_url/2 (their popup shows Shelly's own login).
  2. Shelly redirects to your redirect_uri with a code param.
  3. exchange_code/2 swaps it for an access token. Its options are :client_id and :req_options (merged into the request — a proxy, custom timeouts, or a stub in tests).

The token authorizes the account API (Shelly.Account) as an Authorization: Bearer header and the real-time websocket (Shelly.Events).

Token lifetime

Access tokens expire. Measured against a shelly-diy grant on a live account: exp - iat = 43200 seconds — exactly 12 hours — after which every call (and the websocket) returns 401 invalid_token. Read the deadline from :expires_at in the exchange_code/2 result (or from the JWT's exp claim via peek_jwt/1) and renew before it passes, rather than treating the token as permanent.

refresh/2 renews the token, and this works — measured on a live account, which renewed itself unattended three times over 36 hours, every ~11 hours, with no user interaction. Shelly does not document a refresh grant, so treat it as behaviour that could change rather than a contract: renew before the deadline, and handle {:error, :refresh_unsupported} by sending the user through authorize_url/2 again.

The one thing renewal cannot survive is your application being down across a deadline — nothing renews while nothing is running. An auth key (Shelly.Client.put_auth_key/2) covers that gap, since it does not expire.

The default shelly-diy client id is for personal/DIY integrations; commercial integrators get their own via Shelly support (support@shelly.cloud).

Summary

Functions

URL to open (usually in a popup) to start the grant.

Exchange an authorization code for an access token.

Read a JWT's payload without verification (routing only — do not trust).

Best-effort renewal of an access token that is about to expire.

Functions

authorize_url(redirect_uri, opts \\ [])

@spec authorize_url(
  String.t(),
  keyword()
) :: String.t()

URL to open (usually in a popup) to start the grant.

Options:

  • :state — an opaque value Shelly echoes back to your callback. This library only passes it through: generate one per attempt (Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)), keep it in the user's session, and compare it before calling exchange_code/2. Without that comparison an attacker can feed your callback a code from a different account.
  • :client_id — defaults to "shelly-diy".

exchange_code(code, opts \\ [])

@spec exchange_code(
  String.t(),
  keyword()
) :: {:ok, Shelly.Client.t()} | {:error, term()}

Exchange an authorization code for an access token.

Returns {:ok, %Shelly.Client{}} — ready to pass to Shelly.Account, Shelly.Events and refresh/2. The client's :server comes from the token's own JWT claims (the account's home cloud server), and :label is a best-effort account identifier.

Persist :expires_at and :refresh_token — an access token that looks permanent stops working 12 hours in.

peek_jwt(token)

@spec peek_jwt(String.t() | nil) :: map() | nil

Read a JWT's payload without verification (routing only — do not trust).

refresh(account, client_id \\ "shelly-diy")

@spec refresh(Shelly.Client.t(), String.t()) ::
  {:ok, Shelly.Client.t()} | {:error, :refresh_unsupported | :no_token | term()}

Best-effort renewal of an access token that is about to expire.

Shelly publishes no refresh grant for the Cloud Control API, but the conventional one (grant_type=refresh_token) is honoured in practice: verified against a live account that renewed itself repeatedly. The request goes to the account's own server, sending the stored refresh token when there is one and the current access token otherwise.

Renew before the deadline. An expired token is rejected outright — every grant variant answers 401 invalid_token — so there is no recovering a lapsed session this way, only keeping a live one alive.

Takes the client whose token is expiring and returns a fresh one, carrying the refresh token and expiry forward.

Returns {:ok, client} shaped exactly like exchange_code/2, {:error, :refresh_unsupported} when the server rejects the attempt — which is what a token that has already expired gets — or {:error, reason} on transport failure.

case Shelly.OAuth.refresh(client) do
  {:ok, refreshed} -> store(refreshed.token, refreshed.expires_at)
  {:error, :refresh_unsupported} -> ask_user_to_reconnect()
end