Shelly.OAuth (Shelly v0.2.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 non-standard variant — there is no state parameter for CSRF binding, and the authorization code is itself a JWT whose (unverified) claims name the account's cloud server. Server claims are pinned to https://*.shelly.cloud before use.

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.

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 attempts a renewal. Shelly does not document a refresh grant, so it is best-effort: it returns {:error, :refresh_unsupported} when the server rejects the attempt, and callers should fall back to sending the user through authorize_url/2 again — or to a never-expiring auth key (Shelly.CloudV2) for HTTP polling and control.

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.

Convert an exchange_code/2 result into the account map that Shelly.Account and Shelly.Events take.

Functions

authorize_url(redirect_uri, client_id \\ "shelly-diy")

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

exchange_code(code, client_id \\ "shelly-diy")

Exchange an authorization code for an access token.

Returns {:ok, %{access_token: token, refresh_token: refresh, expires_at: datetime, user_api_url: url, label: label}}user_api_url is the account's home cloud server (read from the token's JWT claims), label a best-effort account identifier.

:expires_at is a DateTime derived from the token's exp claim (nil if the token carries no expiry); :refresh_token is whatever the server returned under refresh_token, or nil. Persist both — a token that looks permanent stops working 12 hours in.

peek_jwt(token)

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

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

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

Shelly publishes no refresh grant for the Cloud Control API, so this tries the conventional one (grant_type=refresh_token) against the account's own server, sending the stored refresh token when there is one and the current access token otherwise.

Takes the same account map as the rest of the library (%{server: url, token: token}, e.g. from to_account/1), plus an optional :refresh_token.

Returns {:ok, result} shaped exactly like exchange_code/2 when the server plays along, {:error, :refresh_unsupported} when it rejects the attempt (the expected outcome today — re-authorize the user instead), or {:error, reason} on transport failure.

case Shelly.OAuth.refresh(account) do
  {:ok, %{access_token: token, expires_at: at}} -> store(token, at)
  {:error, :refresh_unsupported} -> ask_user_to_reconnect()
end

to_account(map)

Convert an exchange_code/2 result into the account map that Shelly.Account and Shelly.Events take.