The shortcut to your own Gladys Assistant instance.
This is a static site, deployed on Cloudflare Pages. It has no backend, no database and no analytics. Its only job is to redirect a browser to the Gladys instance running on the visitor's own network.
OAuth2 providers now require the redirect URI of an application to be HTTPS.
Spotify, for instance, enforces it since April 2025:
only https://... is accepted, plus the loopback literals http://127.0.0.1:PORT
and http://[::1]:PORT — localhost is refused, and so is any LAN address such
as http://192.168.1.50:1443.
That is exactly how most people run Gladys at home: a local IP, over plain HTTP, with no certificate. There is no way to satisfy the provider from there.
So https://my.gladysassistant.com/redirect/oauth is registered as the redirect
URI instead. It is served over HTTPS, the provider accepts it, and it bounces the
browser back to the instance. This is the same approach Home Assistant takes with
my.home-assistant.io.
Gladys (http://192.168.1.50:1443)
│ 1. the user clicks "Connect" on an integration
│ the frontend wraps the integration's state with its own origin
▼
Provider (Spotify…)
│ 2. the user grants access
│ redirect_uri = https://my.gladysassistant.com/redirect/oauth
▼
my.gladysassistant.com/redirect/oauth
│ 3. unwraps the state, checks the target, shows it, waits for a click
▼
Gladys (http://192.168.1.50:1443/dashboard/…/oauth-callback?code=…&state=…)
4. the instance verifies the state and exchanges the code itself
Step 4 never leaves the local network: the token exchange is made by the Gladys instance, with its own client secret. No token or credential ever transits through this page, and nothing is stored here — everything happens in the visitor's browser.
To be precise rather than reassuring: the provider redirect does carry the
authorization code in the query string of a request to this host, so the static
hosting in front of it sees that URL. No code runs server-side, nothing is
logged on purpose, the query string is stripped from the address bar once read,
and the code is useless on its own — the exchange needs the client secret, which
never leaves the instance. Do not claim that no data ever touches
Gladys-operated infrastructure; users who want none at all can point their
instance at its own HTTPS callback instead.
The provider hands the state parameter back untouched, so it is the only way to
carry the instance address across the round trip. Before starting the flow, the
Gladys frontend builds:
state = base64url(
JSON.stringify({
v: 1,
origin: 'http://192.168.1.50:1443',
path: '/dashboard/integration/device/external/ext-dev-spotify/oauth-callback',
state: '<the anti-CSRF state generated by the integration>',
}),
);| Field | Description |
|---|---|
v |
Format version, currently 1. |
origin |
Origin of the instance, as the user reaches it. No path, no query, no credentials. |
path |
Absolute path of the callback route inside the instance. |
state |
The state generated by the integration. Only the instance can verify it. |
This page then redirects to origin + path, forwarding every query parameter
of the provider (code, but also error, scope… which vary between providers)
and putting the inner state back in place. The instance sees exactly what it
would have seen from a direct redirect.
assets/js/oauth-state.js is the reference implementation of the format, and
exports encodeWrappedState() for clients.
The page is deliberately narrow:
- Redirect targets are restricted. Any
https://origin is accepted (the user set up TLS themselves), but a plainhttp://origin is only accepted when it can exist solely on the visitor's own network: RFC 1918 ranges, loopback, link-local, the CGNAT range Tailscale uses, IPv6 ULA/link-local,localhost,.local/.lan/.home/.internal/.home.arpa, and single-label hostnames. Anything else is refused, so this cannot become an open redirect that leaks an authorization code to a public host over plain HTTP. - The redirect is never automatic. The visitor sees the target address and clicks. Nothing is forwarded silently.
- Paths cannot escape the origin. No
//, no backslash, no query, no fragment, and the rebuilt URL is checked against the declared origin. - The query string is stripped from the address bar once read, so the authorization code does not linger in history or in a screenshot.
- Nothing third-party runs on this origin.
_headerssets a strict CSP (default-src 'none',script-src 'self',connect-src 'none',frame-ancestors 'none'),Referrer-Policy: no-referrerandCache-Control: no-storeon/redirect/*. Keep it that way: no CDN font, no analytics, no tag manager. A script on this origin could read the authorization code. - No cookie may be set on the apex domain
gladysassistant.com, or it would be sent to this subdomain too.
No build step, no dependency. Serve the repository root with any static server:
python3 -m http.server 8000
# then open http://127.0.0.1:8000Run the tests (Node.js 18+):
npm testTo exercise the redirect page by hand, generate a state:
node -e "import('./assets/js/oauth-state.js').then(({ encodeWrappedState }) => \
console.log(encodeWrappedState({ origin: 'http://192.168.1.50:1443', \
path: '/dashboard/integration/device/external/ext-dev-spotify/oauth-callback', \
state: 'demo' })))"then open /redirect/oauth?code=demo-code&state=<the output>.
Add ?lang=fr or ?lang=en to any page to force a language.
Cloudflare Pages, from the main branch:
| Setting | Value |
|---|---|
| Framework preset | None |
| Build command | (empty) |
| Build output | / (repository root) |
_headers is applied by Cloudflare Pages automatically. 404.html is served for
unknown paths.
/redirect/oauth is the first entry of a namespace meant to grow: future pages
such as /redirect/integration/<selector> can open a given page of the visitor's
own instance from the documentation, the blog or the community forum. Any new
redirect target must reuse the origin checks in assets/js/oauth-state.js.
Apache-2.0, like Gladys Assistant.