Signing In with OAuth
Some data lives behind a user's account on another service: what they're playing, their calendar, their orders. A plugin reaches that data with OAuth, and FiestaBoard does the hard part for you. You declare the provider in manifest.json and ask for a token in fetch_data. The platform runs the sign-in, stores the tokens, refreshes them, and draws the sign-in button in your plugin's settings.
This page is the complete reference for plugin authors, human or AI. It assumes you have read the Plugin Development Guide. For what your users see, read Connecting Accounts.
Requires FiestaBoard 9.5.0 or later. Set "fiestaboard_version": ">=9.5.0" in your manifest.
The Short Version
- Add an
oauthblock tomanifest.jsonnaming the provider's endpoints and the scopes you need. - In
fetch_data, callself.get_oauth_token(). If it returnsNone, return an unavailable result that tells the user to sign in. Otherwise send it as a bearer token. - Never write an OAuth flow, store a token, or ship a client secret. The platform owns all three.
{
"id": "example_music",
"name": "Example Music",
"version": "1.0.0",
"fiestaboard_version": ">=9.5.0",
"oauth": {
"provider_name": "Example Music",
"flows": ["relay"],
"authorization_url": "https://example.com/oauth/authorize",
"token_url": "https://example.com/oauth/token",
"scopes": ["user-read-currently-playing"]
},
"settings_schema": {
"type": "object",
"properties": {
"client_id": {
"type": "string",
"title": "Client ID",
"description": "From the app you created in Example Music's developer settings."
}
}
}
}
import requests
from src.plugins.base import PluginBase, PluginResult
NOT_SIGNED_IN = "Not signed in to Example Music. Open this plugin's settings and sign in."
class ExampleMusicPlugin(PluginBase):
@property
def plugin_id(self) -> str:
return "example_music"
def fetch_data(self) -> PluginResult:
try:
token = self.get_oauth_token()
if not token:
return PluginResult(available=False, error=NOT_SIGNED_IN)
response = requests.get(
"https://api.example.com/v1/me/now-playing",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if response.status_code == 401:
return PluginResult(available=False, error="Example Music rejected the sign-in. Press Reconnect.")
response.raise_for_status()
return PluginResult(available=True, data={"title": response.json()["title"].upper()})
except Exception as exc: # fetch_data must never raise
return PluginResult(available=False, error=str(exc))
Plugin = ExampleMusicPlugin
That is a working OAuth plugin. The rest of this page explains each choice.
Why the Platform Does This
A FiestaBoard usually lives on a home network at an address like http://192.168.1.50:4420. OAuth providers only send a signed-in user back to an https:// address registered in advance, and a board is neither public nor HTTPS. A plugin cannot solve that by itself, so the platform solves it once for every plugin, with two flows:
| Flow | How the user signs in | Use it when |
|---|---|---|
relay | The browser goes to the provider, then returns through a small static page at https://fiestaboard.app/auth/oauth/redirect, which hands it back to the board. Authorization code with PKCE. | Always available: every OAuth provider supports it. |
device | The settings show a short code. The user enters it on the provider's site from any device, and the board waits for approval. Device authorization grant (RFC 8628). | The provider supports device codes for the scopes you need. No redirect is involved. |
The redirect URI for the relay flow is the same for every board and every plugin:
https://fiestaboard.app/auth/oauth/redirect
FiestaBoard 9.5.0 through 9.7.x sends the same address with .html on the end. Both reach the same page, but providers compare redirect URIs exactly. So in your setup guide, tell users to copy the redirect URI from the plugin's settings, which always shows the one their board sends, instead of typing one from your docs.
The first time someone signs in from a browser, that page shows the board's address and asks them to confirm it, then remembers the answer. It only ever forwards to an address on a local network. You do not build, host, or configure any of it.
The oauth Block
| Field | Required | Meaning |
|---|---|---|
flows | yes | ["relay"], ["device"], or both. The first one listed is the one the sign-in button uses. |
token_url | yes | The provider's token endpoint. |
authorization_url | for relay | The provider's authorization endpoint. |
device_authorization_url | for device | The provider's device authorization endpoint. |
scopes | no | Scopes to request. Ask for the least that works; users see the list on the consent screen. |
provider_name | no | Shown in the UI ("Sign in with Example Music"). Defaults to your plugin's name. |
client_id | no | A client ID shipped with the plugin. See Whose App?. |
client_id_setting | no | The settings_schema key that holds a user's own client ID. Default client_id. |
client_secret_setting | no | The settings_schema key that holds a user's client secret, for providers that require one. Omit it for a PKCE-only client. |
authorization_params | no | Extra fixed query parameters for the authorization request, as strings. For example {"access_type": "offline"}. |
app_setup_url | no | The provider's developer page, where a user creates their own app. The guided setup links to it. https:// only. Added in 9.8.0; see the note on unknown fields below. |
The manifest is validated when the plugin loads, and a plugin with a bad oauth block is refused with a message naming the problem:
- Endpoints must be
https://. Plainhttp://is accepted only forlocalhost,127.0.0.1, and::1, which is what a local test provider looks like. flowsmust be a non-empty list ofrelayanddevice, without repeats.client_secretis never allowed in a manifest. Plugin repositories are public.- A field your board's FiestaBoard does not know is ignored and reported as a warning in
GET /plugins/errors, so a plugin using a field from a newer release still loads on an older one. 9.5.0 through 9.7.x refuse the whole plugin instead, so a plugin that must run on those releases cannot useapp_setup_url. authorization_paramsmay not setresponse_type,client_id,redirect_uri,state,scope,code_challenge, orcode_challenge_method. The platform sets those.scopesentries are strings without spaces.- Transition plugins may not declare
oauth.
Whose App?
Every OAuth sign-in runs under an "app" (or "client") registered with the provider. You choose who registers it. The choice decides what the user sees.
Each user registers their own app
Declare a client_id field in settings_schema and do not ship a client_id in the oauth block. The user creates an app in the provider's developer settings, gives it the redirect URI, and pastes the app's client ID into your plugin's settings.
FiestaBoard turns this into a guided setup in the plugin's Account connection section: numbered steps, a link to the provider's developer page (set app_setup_url), a link to your docs/SETUP.md, the redirect URI with a copy button, and the Client ID field itself. The sign-in button saves what was typed and starts the sign-in in one press. Your client ID field (and client secret field, if any) appears there and not in the general settings form, so declare it plainly and do not mark it required: the panel already will not sign in without it.
This works for every user without limits, and it is the right default: most providers restrict apps they have not reviewed. Your docs/SETUP.md must still walk through creating the app, with what to enter in each of the provider's fields.
If the provider requires a client secret, add "client_secret_setting": "client_secret" and a matching password field:
"client_secret": {
"type": "string",
"title": "Client Secret",
"ui:widget": "password"
}
The names client_id and client_secret are masked in API responses automatically. The secret is stored on the user's board and sent only to the provider's token endpoint.
The plugin brings its own app
Ship your app's client ID in the oauth block and offer no client ID field:
"oauth": {
"provider_name": "Spotify",
"flows": ["relay"],
"authorization_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"scopes": ["user-read-playback-state"],
"client_id": "0123456789abcdef0123456789abcdef"
}
Users open the settings and see one button, Sign in with Spotify, and nothing to set up. A client ID is not a secret, so publishing it is fine. This only works with providers that support PKCE without a client secret, because a secret cannot ship.
Before you choose this, check the provider's limits on apps they have not reviewed. Spotify, for example, admits only five hand-added accounts to an app in Development Mode and offers no wider mode to open-source projects. Every other user signs in successfully and then gets 403 from the API. If your provider has a limit like this, say so at the top of your setup guide and make your 403 message explain it.
The rule that decides
A client ID or secret saved in the plugin's config only counts if settings_schema declares that field.
| Manifest | Client ID used |
|---|---|
oauth.client_id shipped, no settings field | Always the shipped one. A value saved earlier or through the API is ignored. |
oauth.client_id shipped, settings field declared | The user's value if they saved one, otherwise the shipped one. |
No oauth.client_id, settings field declared | The user's value. The sign-in button is disabled until one is saved. |
On FiestaBoard 9.5.0 through 9.7.x a saved value always overrode a shipped client ID, and the settings showed app-setup help even when there was nothing to set up. 9.8.0 and later follow the table above.
Using the Token
self.get_oauth_token() returns the current access token as a string, or None.
- Call it on every fetch and use what it returns. Do not keep the token in an attribute, a cache, or a file. The platform refreshes it shortly before it expires, and the next call returns the new one.
Nonemeans the user is not signed in, or has to sign in again. You cannot tell the two apart, so word the message to cover both: "Open this plugin's settings and sign in." ReturnPluginResult(available=False, error=...)and make no request.- Each plugin instance has its own connection. Two instances of your plugin can be signed in to two accounts. Another plugin never sees your tokens.
Handling the provider's answers
The platform manages the token. Your plugin still has to handle what the provider's API says:
| Answer | What it means | What to do |
|---|---|---|
401 | The token was rejected, usually because the user revoked access. | Return unavailable and tell the user to press Reconnect. Do not retry on every render. |
403 | Signed in, but not allowed. Common with apps the provider limits to listed accounts. | Return unavailable with a message that says what to do about it. |
429 | Rate limited. | Honor Retry-After. Do not call again until it has passed. |
5xx, timeout | The provider is having trouble. | Back off for a short while. Consider showing the last good data briefly. |
Two platform behaviors make this your job rather than something you get for free:
- Unavailable results are not cached. After a failure,
fetch_datais called again on the next render. Without your own cooldown, a rate-limited plugin keeps hitting the provider. - Results are cached per board shape. A Flagship and a Note showing the same plugin fetch separately. For a rate-limited API, keep one shared snapshot inside the plugin for a few seconds so that both are served by one request.
A plugin cannot tell the platform that a token was rejected, so the settings may keep showing Connected until the platform's next refresh fails. Your error message is what the user sees in the meantime, so make it actionable.
Guarding against an older FiestaBoard
Setting fiestaboard_version to >=9.5.0 is the main protection. If you want a friendly message on an older board that somehow loads the plugin:
get_token = getattr(self, "get_oauth_token", None)
if get_token is None:
return PluginResult(available=False, error="Update FiestaBoard to use this plugin.")
What Your Users See
You build no UI. When a plugin declares oauth, its settings gain an Account connection section:
- Not connected: a Sign in with Provider button. With the user's-own-app model it sits under the guided setup described in Whose App? and stays disabled until a Client ID is entered.
- Waiting for approval (device flow): the address to visit and the code to enter, with a copy button. It updates by itself when the user approves.
- Connected: Reconnect and Disconnect buttons.
- Reconnect needed: the provider refused a token refresh. Your plugin gets
Noneuntil the user signs in again.
After a relay sign-in the user lands back on the Integrations page with your plugin's settings open and a confirmation. Failures arrive there too, as a sentence.
Testing
Unit tests
Do not test the OAuth flow; the platform does. Test what your plugin does with a token and without one. Replace get_oauth_token on the instance and mock the provider's API as you would for any HTTP plugin:
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from plugins.example_music import ExampleMusicPlugin
MANIFEST = json.loads((Path(__file__).parent.parent / "manifest.json").read_text())
@pytest.fixture
def plugin():
p = ExampleMusicPlugin(MANIFEST)
p.get_oauth_token = lambda: "test_access_token"
return p
def test_sends_the_token_as_a_bearer_header(plugin):
with patch("plugins.example_music.requests.get") as get:
get.return_value = Mock(status_code=200, json=lambda: {"title": "Low Tide"})
result = plugin.fetch_data()
assert result.available is True
assert get.call_args.kwargs["headers"]["Authorization"] == "Bearer test_access_token"
def test_makes_no_request_when_not_signed_in(plugin):
plugin.get_oauth_token = lambda: None
with patch("plugins.example_music.requests.get") as get:
result = plugin.fetch_data()
assert result.available is False
assert "sign in" in result.error.lower()
get.assert_not_called()
Also cover 401, 403, 429 with Retry-After, a timeout, and that a new token is used immediately after a rejection.
Validate your oauth block with the platform's own validator, so that a mistake fails in your CI and not at install time:
from src.oauth.provider import parse_provider_block, validate_provider_block
def test_oauth_block_is_valid_and_has_no_secret():
block = MANIFEST["oauth"]
assert validate_provider_block(block) == []
assert "client_secret" not in block
assert parse_provider_block(block, MANIFEST["name"]).flows == ("relay",)
Use invented account data in fixtures, and obviously fake tokens such as test_access_token.
CI
A plugin repository's CI checks out FiestaBoard to run against. Pin that checkout to a release that has OAuth, and keep it in step with fiestaboard_version:
- uses: actions/checkout@v4
with:
repository: Fiestaboard/FiestaBoard
ref: v9.5.0 # keep equal to fiestaboard_version in manifest.json
path: fiestaboard-core
End to end, without a real account
FiestaBoard ships a strict stand-in provider, scripts/mock_oauth_provider.py. It requires PKCE, issues single-use codes, rotates refresh tokens, and expires access tokens after 20 seconds so that refresh happens while you watch.
-
Start the development stack with port 9400 published, because the relay flow sends your browser to the provider. Save this next to
docker-compose.dev.ymlasdocker-compose.oauth-test.yml:services:fiestaboard:ports:- "9400:9400"docker compose -f docker-compose.dev.yml -f docker-compose.oauth-test.yml up -ddocker compose -f docker-compose.dev.yml exec -d fiestaboard python scripts/mock_oauth_provider.py -
Put a copy of your plugin in
data/external_plugins/<plugin_id>/and, in that copy only, point the endpoints at the mock:"authorization_url": "http://localhost:9400/authorize","device_authorization_url": "http://localhost:9400/device/code","token_url": "http://localhost:9400/token"Point the copy's API calls at
http://localhost:9400/metoo, or stub them. Restart the container so the plugin loads. -
Open
http://localhost:4420, go to Integrations, open the plugin's settings, and sign in. The relay flow passes through the realfiestaboard.apppage and back. For the device flow, approve the code as if from another device:curl "http://localhost:9400/device/approve?user_code=<CODE>" -
Check the cases that matter. A plugin's results are cached for its refresh interval (five minutes unless it declares
refresh_seconds), so to make the plugin fetch again straight away, turn it off and on with its toggle on the Integrations page.- Wait 20 seconds and make it fetch: the board refreshes the token and your plugin keeps working.
curl -X POST http://localhost:9400/revoke-all, then make it fetch: the settings show Reconnect needed and your plugin reports that it is not signed in.- Press Disconnect: your plugin reports that it is not signed in on its next fetch. Signing in and disconnecting clear the plugin's cache, so these two take effect without the toggle.
curl http://localhost:9400/logshows every request the provider saw, with secrets replaced by their lengths.
Never commit the copy that points at the mock. Finish with one sign-in against the real provider before you publish.
Documenting It for Your Users
Your docs/SETUP.md should cover, in this order:
- What the plugin can see. List the scopes in plain words and say what it cannot do ("It can't play, pause, or change anything").
- Creating the app, if users register their own: where to go, which fields matter, and that the redirect URI is copied from the plugin's settings. Mention any plan or account requirement the provider has.
- Signing in: open the settings, press Sign in with Provider, approve, and confirm the board's address the first time. Link to Connecting Accounts for the details.
- Limits, such as a cap on accounts, stated before the user hits them.
- Troubleshooting keyed by the exact error text your plugin shows.
Provider Notes
Check the provider's current documentation; these are starting points, not guarantees.
| Provider | Flow | Notes |
|---|---|---|
| Spotify | relay | PKCE without a secret. No device flow. Development Mode apps admit five listed accounts, and the redirect URI must match exactly. |
relay | Web-application clients need a client secret, so use client_secret_setting. Send {"access_type": "offline", "prompt": "consent"} in authorization_params to receive a refresh token. The device flow allows only a short list of scopes. | |
| GitHub | device or relay | The device flow must be enabled in the app's settings. GitHub reports OAuth errors with HTTP 200 and separates scopes with commas; the platform handles both. |
What the Platform Guarantees
- PKCE on every relay sign-in. The code verifier is created on the board and never leaves it, so an authorization code is useless to anything that sees it in transit.
- A signed, single-use, ten-minute
state. A callback that this board did not start, that has expired, or that was already used is refused. - Tokens stay on the board. They are stored in
data/oauth_tokens.json, readable only by the FiestaBoard process, never included in an API response, and never written to backups. - One connection per plugin instance. Tokens are not shared between plugins.
- Cleanup. Uninstalling a plugin, deleting an instance, or pressing Disconnect deletes the tokens. Disconnecting and signing in also clear the plugin's cached results, so the board stops showing an account's data as soon as the user disconnects it.
- Refresh. Access tokens are refreshed a minute before they expire. If the provider refuses, the connection becomes Reconnect needed and is not retried on every fetch.
Checklist
-
fiestaboard_versionis>=9.5.0 - The
oauthblock passesvalidate_provider_blockin a test - No client secret anywhere in the repository
- Scopes are the least the plugin needs
-
fetch_datacallsget_oauth_token()every time and stores nothing -
Nonereturns an unavailable result that says to sign in, and makes no request -
401,403,429,5xx, and timeouts are handled with a cooldown - One request serves every board shape, if the API is rate limited
- Tests use invented data and fake tokens
- CI is pinned to a FiestaBoard release with OAuth
-
docs/SETUP.mdcovers permissions, app creation (if any), sign-in, limits, and troubleshooting - One sign-in has been tested against the real provider
Worked Example: Spotify
The Spotify plugin is the reference implementation. It is worth reading in full:
manifest.json: therelayflow, three read-only scopes, and aclient_idsetting, because Spotify limits a shared app to five accounts and so each user brings their own.__init__.py: one shared snapshot for every board shape, cooldowns for401,403,429, and outages, and stale data shown briefly during an outage.tests/:get_oauth_tokenreplaced on the instance, a fake Spotify API, and the platform's validator run against the manifest.docs/SETUP.md: a setup guide that walks through creating the Spotify app, field by field.
Next Steps
- Plugin Development Guide - Everything else about building a plugin
- Connecting Accounts - The sign-in from the user's side
- Testing Guide - Running and writing tests