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:
- Send the user to
authorize_url/2(their popup shows Shelly's own login). - Shelly redirects to your
redirect_uriwith acodeparam. exchange_code/2swaps 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
URL to open (usually in a popup) to start the grant.
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.
Read a JWT's payload without verification (routing only — do not trust).
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
Convert an exchange_code/2 result into the account map that
Shelly.Account and Shelly.Events take.