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:
- 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. Its options are:client_idand: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
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 callingexchange_code/2. Without that comparison an attacker can feed your callback a code from a different account.:client_id— defaults to"shelly-diy".
@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.
Read a JWT's payload without verification (routing only — do not trust).
@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