Skip to content

Optional authentication token for API and Web UI - #93

Open
valentinocossar wants to merge 1 commit into
jfmlima:mainfrom
valentinocossar:feature/admin-token-auth
Open

Optional authentication token for API and Web UI#93
valentinocossar wants to merge 1 commit into
jfmlima:mainfrom
valentinocossar:feature/admin-token-auth

Conversation

@valentinocossar

@valentinocossar valentinocossar commented Aug 24, 2026

Copy link
Copy Markdown

Summary

Closes #90. Adds an optional, off-by-default admin token that gates both the API and the Web UI, as a shared-secret token rather than HTTP Basic Auth or cookies (see the issue for why: cross-origin API/Web UI deployments break both of those cleanly).

  • API: SHELLY_AUTH_TOKEN (unset by default = no auth, same zero-config philosophy as the rest of the app). A Litestar guard checks Authorization: Bearer <token> on every route except GET /api/health, GET /api/auth/config and /docs, which stay public. New GET /api/auth/config ({"enabled": bool}) and GET /api/auth/verify back the Web UI's login flow.
  • Web UI: a dedicated login page (not a browser Basic Auth prompt) collects the token, verifies it against /api/auth/verify, and stores it in localStorage if "Remember me" is checked or sessionStorage otherwise. Every request attaches it via an axios interceptor; a 401 anywhere clears it and bounces back to /login.

Open to feedback on the approach (env var placement/naming, /docs staying public, the shared-secret + verify-endpoint model) - happy to adjust based on how you'd want this to look.

Breaking change (opt-in only)

Nothing changes for anyone who leaves SHELLY_AUTH_TOKEN unset - default behavior is identical to before this PR. But for anyone who sets it: every existing integration that talks to the API directly (Home Assistant, scripts, curl in a cron job, etc.) will start getting 401s until it's updated to send Authorization: Bearer <token>. Worth calling out explicitly since it is not purely additive once someone opts in.

Test plan

  • make lint (black, ruff, mypy) - clean
  • make test-core - 996 passed
  • make test-api - 129 passed (13 new: guard behavior, /auth/config + /auth/verify, app-level router composition with/without a token set)
  • npm run type-check, npm run lint, npm run format:check, npm run build - clean
  • Manual end-to-end check with both servers running locally: token unset → everything open as before; token set → /api/health, /api/auth/config and /docs stay public, everything else 401s without the header and works with Authorization: Bearer <token>
  • Visual/manual click-through of the login page in a browser

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

https://claude.ai/code/session_016kaiVXDHedKfqLgfdGNMwx

Shelly Manager had no login of its own, so anyone reaching the host had
full control of stored device credentials, firmware updates and backups
(jfmlima#90). Set SHELLY_AUTH_TOKEN to require it - off by default.

- API: a Litestar guard checks Authorization: Bearer <token> against
  SHELLY_AUTH_TOKEN on every route except /api/health, /api/auth/config
  and /docs, which stay public. New GET /api/auth/config and
  GET /api/auth/verify endpoints back the Web UI's login flow.
- Web UI: a login page collects the token, stores it in localStorage
  (Remember me) or sessionStorage otherwise, attaches it to every
  request, and bounces back to /login on a 401.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kaiVXDHedKfqLgfdGNMwx
@valentinocossar
valentinocossar marked this pull request as ready for review August 24, 2026 16:24
@valentinocossar valentinocossar changed the title feat: optional shared-token authentication for API and Web UI Optional authentication token for API and Web UI Aug 24, 2026

@jfmlima jfmlima left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey again @valentinocossar, thanks for this and appreciate the minimal solution.
Left some comments, let me know what you think.

// The login form's own token check 401s on a wrong password - that's
// expected and handled inline, not a session being kicked out.
const isAuthVerify = error.config?.url?.includes("/auth/verify");
if (error.response?.status === 401 && !isAuthVerify) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API also returns 401 for DeviceAuthenticationError (a password protected device with missing or wrong stored credentials), and this treats any non-verify 401 as an expired session, clears the token and redirects to /login. It does that even when SHELLY_AUTH_TOKEN is unset, since the interceptor is unconditional, so opening the detail page of a password protected device kicks the user to the login screen. What do you think about adding WWW-Authenticate: Bearer to the guard's 401 (RFC 7235 wants it there anyway) and only treating 401s carrying that header as a manager logout?


header = connection.headers.get("authorization", "")
presented = header.removeprefix("Bearer ") if header.startswith("Bearer ") else ""
if not presented or not hmac.compare_digest(presented, token):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmac.compare_digest on str raises TypeError when either side contains non-ASCII, so a garbage Authorization header becomes a logged 500 instead of a 401. Can we compare presented.encode() against token.encode()?

header = connection.headers.get("authorization", "")
presented = header.removeprefix("Bearer ") if header.startswith("Bearer ") else ""
if not presented or not hmac.compare_digest(presented, token):
raise UnauthorizedError()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in core raises this, bearer transport is purely an API concern, so I'd rather keep it out of the domain layer. If the guard raises Litestar's NotAuthorizedException instead, the existing HTTPException handler already produces the same envelope, and both the core exception and the new EXCEPTION_HANDLERS entry can go.


const form = useForm<LoginFormData>({
resolver: zodResolver(loginFormSchema),
defaultValues: { token: "", rememberMe: true },

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we default rememberMe to false? Checked by default makes localStorage the default home for a token that grants full control.

Comment thread README.md
export SHELLY_AUTH_TOKEN="a-strong-random-token"
```

When set, every API request (except `/api/health` and `/api/auth/config`) must include `Authorization: Bearer <token>`, and the Web UI shows a login page asking for the token before it will load. Leave it unset to keep the zero-configuration default. Unlike `SHELLY_SECRET_KEY`, this is optional and unrelated to credential encryption.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/docs and /docs/openapi.json also stay public, can we list them here? Worth also recommending a long random token, since there's no rate limiting on /auth/verify.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Built-in authentication for the Web UI and API

2 participants