Dropbox access tokens expire about four hours after they are issued — long-lived tokens have not been handed out since 2021. An application that runs longer than an afternoon cannot rely on a token pasted into a config file: it needs a refresh token, and something that trades it for a fresh access token before the old one dies.
That "something" is Magpie.Auth.TokenServer, and Magpie wires it into
every request for you:
client =
Magpie.Client.new(
refresh_token: System.fetch_env!("DROPBOX_REFRESH_TOKEN"),
app_key: System.fetch_env!("DROPBOX_APP_KEY"),
app_secret: System.fetch_env!("DROPBOX_APP_SECRET")
)
# Still works at 3am, three weeks later
Magpie.Files.upload_file(client, "/Backup/db.dump", "priv/db.dump")The rest of this guide is about getting that refresh token, and about running the token server the way a real application should.
The three ways to authenticate
| Form | When to use |
|---|---|
Magpie.Client.new("ACCESS_TOKEN") | scripts and one-off iex sessions — the token dies in ~4h |
Magpie.Client.new(refresh_token: ..., app_key: ..., app_secret: ...) | a single Dropbox account, one process per client |
Magpie.Client.new(token_provider: {module, arg}) | a supervised token server, or your own storage |
They all end up behind a Magpie.Auth.TokenProvider — the client never
holds a token itself, it asks the provider for one on every request.
Getting a refresh token
From the App Console, in five minutes
Good enough when your app talks to one Dropbox account (backups, report drops, a company folder). No callback URL, no web flow.
Create an app at dropbox.com/developers/apps. Note the App key and App secret.
In the Permissions tab, tick the scopes you need (
files.content.write,files.content.read,account_info.read, ...) and click Submit. Scopes granted later do not apply to tokens already issued — you have to authorize again.Open the authorization URL in your browser:
Magpie.Auth.authorize_url("APP_KEY") # => "https://www.dropbox.com/oauth2/authorize?client_id=APP_KEY&response_type=code&token_access_type=offline"token_access_type=offlineis the part that makes Dropbox return a refresh token —authorize_url/2sets it by default.Approve the app. Dropbox shows an authorization code on screen — it is single-use and expires in minutes.
Trade it for tokens:
{:ok, token} = Magpie.Auth.exchange_code("APP_KEY", "THE_CODE", app_secret: "APP_SECRET") token.refresh_token # => "abcd..." — this one does not expire, store it as a secretPut
token.refresh_tokenin your app's secrets, next to the app key and secret. You are done — the access token intoken.access_tokencan be thrown away, Magpie will mint new ones.
From a web app, with a redirect
When each of your users connects their own Dropbox account, you need the full redirect flow. Register the callback URL under OAuth 2 → Redirect URIs in the App Console first — Dropbox refuses anything else.
defmodule MyAppWeb.DropboxController do
use MyAppWeb, :controller
@scopes ["account_info.read", "files.content.read", "files.content.write"]
def connect(conn, _params) do
state = Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false)
url =
Magpie.Auth.authorize_url(app_key(),
redirect_uri: callback_url(),
state: state,
scope: @scopes
)
conn
|> put_session(:dropbox_state, state)
|> redirect(external: url)
end
# Dropbox sends the user back here with ?code=...&state=...
def callback(conn, %{"code" => code, "state" => state}) do
if state == get_session(conn, :dropbox_state) do
case Magpie.Auth.exchange_code(app_key(), code,
app_secret: app_secret(),
redirect_uri: callback_url()
) do
{:ok, token} ->
{:ok, _account} = MyApp.Accounts.store_dropbox_token(conn.assigns.current_user, token)
conn
|> delete_session(:dropbox_state)
|> put_flash(:info, "Dropbox connected")
|> redirect(to: ~p"/settings")
{:error, %Magpie.Error{} = error} ->
conn
|> put_flash(:error, "Dropbox refused the authorization: #{Exception.message(error)}")
|> redirect(to: ~p"/settings")
end
else
# Mismatched state — not a callback we started
conn |> put_status(:bad_request) |> text("invalid state")
end
end
# The user clicked "Cancel"
def callback(conn, %{"error" => error}) do
conn
|> put_flash(:error, "Dropbox authorization failed: #{error}")
|> redirect(to: ~p"/settings")
end
endThe state parameter is what stops an attacker from feeding your callback
someone else's authorization code — always generate it, always compare it.
Without an app secret (PKCE)
Desktop apps, mobile apps and CLIs cannot keep a secret: whatever you ship can be read out of the binary. Those are public apps, and they prove their identity with PKCE instead — a random verifier that never leaves the device, and its SHA-256 challenge, which does.
{verifier, challenge} = Magpie.Auth.pkce_pair()
url =
Magpie.Auth.authorize_url(app_key,
redirect_uri: "http://localhost:4000/dropbox/callback",
state: state,
code_challenge: challenge
)
# ... user approves, you receive the code ...
{:ok, token} =
Magpie.Auth.exchange_code(app_key, code,
code_verifier: verifier,
redirect_uri: "http://localhost:4000/dropbox/callback"
)Keep the verifier around until the exchange — it is the proof that the app which redeems the code is the one that started the flow. From then on, everything works as usual, minus the secret:
Magpie.Client.new(refresh_token: token.refresh_token, app_key: app_key, pkce: true)Running the token server
Magpie.Client.new(refresh_token: ...) starts a Magpie.Auth.TokenServer
linked to the calling process. That is what you want in a script; in an
application you want it supervised, so it outlives the request or job that
happened to build the first client:
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
{Magpie.Auth.TokenServer,
name: MyApp.DropboxToken,
app_key: System.fetch_env!("DROPBOX_APP_KEY"),
app_secret: System.fetch_env!("DROPBOX_APP_SECRET"),
refresh_token: System.fetch_env!("DROPBOX_REFRESH_TOKEN")},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
endThen build clients anywhere, as many as you like — they are just a struct pointing at the server:
defmodule MyApp.Dropbox do
def client, do: Magpie.Client.new(token_provider: {Magpie.Auth.TokenServer, MyApp.DropboxToken})
def backup!(path), do: Magpie.Files.upload_file(client(), "/Backup/#{Path.basename(path)}", path)
endHow the refresh actually happens
- Proactively —
fetch_token/1refreshes as soon as the token is within:refresh_marginseconds of expiring (default 300), so requests almost never meet an expired token. - Reactively — if Dropbox rejects a token anyway (HTTP 401
expired_access_token), Magpie refreshes and replays that request once, transparently. A second rejection is returned to you as aMagpie.Error. - Once at a time — refreshes run inside the server, so a hundred concurrent requests hitting an expired token produce exactly one call to Dropbox; everyone else waits for it and gets the new token.
- Without crashing — a failed refresh (Dropbox down, refresh token
revoked) replies
{:error, %Magpie.Error{}}and keeps the old state. The next call tries again.
One limitation: a request whose body is a stream cannot be replayed,
because the bytes are already gone. In practice that is
Magpie.Files.upload/6 and the upload-session chunks of
Magpie.Files.upload_file/4, which read straight from disk. Those requests
are never auto-retried — they rely on the proactive refresh instead, which
is why the default margin is comfortably larger than any single chunk
upload.
Persisting the token
Dropbox does not rotate refresh tokens, so there is usually nothing to
save. If you do want to keep the current access token around — to survive
restarts without an extra refresh, or to hand it to another system — use
:on_refresh:
{Magpie.Auth.TokenServer,
name: MyApp.DropboxToken,
app_key: app_key,
app_secret: app_secret,
refresh_token: refresh_token,
refresh_margin: 600,
on_refresh: &MyApp.Accounts.store_dropbox_token/1}defmodule MyApp.Accounts do
def store_dropbox_token(%Magpie.Auth.Token{} = token) do
MyApp.Repo.insert!(
%MyApp.DropboxToken{
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: token.expires_at
},
on_conflict: {:replace_all_except, [:id]},
conflict_target: :refresh_token
)
end
endThe callback runs inside the server, so keep it quick — and know that if it raises, the error is logged and the refresh still counts.
Starting from a stored token skips the first refresh:
stored = MyApp.Accounts.dropbox_token!()
{Magpie.Auth.TokenServer,
name: MyApp.DropboxToken,
app_key: app_key,
app_secret: app_secret,
refresh_token: stored.refresh_token,
access_token: stored.access_token,
expires_at: stored.expires_at}Pass access_token and expires_at together — a token whose expiry is
unknown is refreshed right away, which is safe but pointless.
When the refresh token arrives at runtime
Not every application knows its refresh token at boot. On a fresh install nobody has authorized Dropbox yet — the token only appears later, through your OAuth callback, and lives in your database from then on. The server handles this: start it without a refresh token and it sits in an unconfigured state, in the same static supervision tree as always:
children = [
MyApp.Repo,
{Magpie.Auth.TokenServer,
name: MyApp.DropboxToken,
app_key: System.fetch_env!("DROPBOX_APP_KEY"),
app_secret: System.fetch_env!("DROPBOX_APP_SECRET"),
on_refresh: &MyApp.Accounts.store_dropbox_token/1},
MyAppWeb.Endpoint
]While unconfigured, every call through this provider fails fast with a pattern-matchable error — no HTTP involved:
case Magpie.Users.current_account(client) do
{:ok, account} -> account
{:error, %Magpie.Error{summary: "no_refresh_token"}} -> :dropbox_not_connected
{:error, error} -> raise error
endHand the server its token whenever you get one, with
Magpie.Auth.TokenServer.set_refresh_token/3:
# On boot, if a previous session stored one — run this after your storage
# is up, e.g. right after Supervisor.start_link/2 returns (or from a Task
# placed after the Repo in the tree). Seeding the pair skips a refresh:
if stored = MyApp.Accounts.dropbox_token() do
Magpie.Auth.TokenServer.set_refresh_token(MyApp.DropboxToken, stored.refresh_token,
access_token: stored.access_token,
expires_at: stored.expires_at
)
end
# In the OAuth callback, right after the code exchange — this is also how
# re-authorization works, no restart needed:
{:ok, token} = Magpie.Auth.exchange_code(app_key(), code, app_secret: app_secret())
:ok = Magpie.Auth.TokenServer.set_refresh_token(MyApp.DropboxToken, token.refresh_token)set_refresh_token/3 never calls Dropbox — it stores the token and lets
the next fetch_token/1 do the refresh (or use the seeded pair), so it is
safe inside a web request. Replacing the token also drops any cached access
token, which belonged to the previous authorization.
Multiple accounts, or tokens in your database
One TokenServer holds one account. If your app connects many Dropbox
accounts, either start one server per account or implement
Magpie.Auth.TokenProvider yourself and let your database be the state:
defmodule MyApp.DropboxTokens do
@behaviour Magpie.Auth.TokenProvider
@margin 300
@impl true
def fetch_token(account_id) do
account = MyApp.Accounts.get_dropbox_account!(account_id)
if DateTime.diff(account.expires_at, DateTime.utc_now()) > @margin do
{:ok, account.access_token}
else
refresh_token(account_id)
end
end
@impl true
def refresh_token(account_id) do
account = MyApp.Accounts.get_dropbox_account!(account_id)
with {:ok, token} <-
Magpie.Auth.refresh(app_key(), account.refresh_token, app_secret: app_secret()) do
MyApp.Accounts.update_dropbox_token!(account, token)
{:ok, token.access_token}
end
end
endclient = Magpie.Client.new(token_provider: {MyApp.DropboxTokens, account.id})Two things to keep in mind if you go this way: fetch_token/1 runs on
every request, so it should be cheap (cache it, or read from ETS), and
nothing serializes the refresh for you — two concurrent requests can
refresh twice. Dropbox tolerates that, but a lock or a per-account
GenServer is nicer.
Revoking
{:ok, _} = Magpie.Auth.token_revoke(client)That kills the access token and the refresh token behind it — the user has to authorize the app again.
Testing
Token requests go through the same Req.Test plumbing as everything else,
so they can be stubbed offline (see the "Testing your app" section of the
Examples guide). Requests made from a TokenServer happen
in the server's process, which needs to be allowed explicitly:
test "refreshes the access token" do
server = start_supervised!({Magpie.Auth.TokenServer, app_key: "k", app_secret: "s", refresh_token: "rt"})
Req.Test.allow(Magpie, self(), server)
Req.Test.stub(Magpie, fn conn ->
assert conn.request_path == "/oauth2/token"
Req.Test.json(conn, %{"access_token" => "sl.FRESH", "expires_in" => 14_400})
end)
assert {:ok, "sl.FRESH"} = Magpie.Auth.TokenServer.fetch_token(server)
end