Skip to content

feat(auth): oauth refresh tokens and persisted oauth tokens - #40

Open
Ailcope wants to merge 1 commit into
Showdown76py:mainfrom
Ailcope:feat/oauth-refresh-tokens
Open

Ailcope wants to merge 1 commit into
Showdown76py:mainfrom
Ailcope:feat/oauth-refresh-tokens

Conversation

@Ailcope

@Ailcope Ailcope commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes the two reasons an authorization_code connector (claude.ai in particular) keeps dropping to "needs authentication":

  1. OAuth access tokens lived in memory only. /oauth/token called token_store.issue(client_id) without a name, so nothing was persisted and every systemctl restart beaconmcp (including a self-update) turned the connector's bearer into a 401.
  2. No refresh token. Access tokens last 24 h and /oauth/token never returned a refresh_token, so the operator had to sign in again with passkey/TOTP at least once a day.

What changed

Refresh tokens (authorization_code grant only)

  • /oauth/token now returns refresh_token next to the access token.
  • New grant_type=refresh_token: same client_id + client_secret_post check as the other grants, issues a new access token and a new refresh token, and rotates the old one out.
  • Reuse detection (OAuth 2.1 / RFC 9700): replaying an already-rotated refresh token revokes the whole family (every access and refresh token from that sign-in). The client has to go through /oauth/authorize again.
  • client_credentials still gets no refresh token: it keeps its fresh-TOTP-on-every-exchange rule.
  • refresh_token is advertised in grant_types_supported (main and DCR metadata) and in the DCR registration response.

Persistence

  • OAuth access tokens (both grants) and refresh tokens go into the existing tokens.db, in two new tables (oauth_tokens, refresh_tokens) created in place on first start. They are stored as SHA-256 hashes and kept in memory keyed by that hash, so the file alone does not hand out working credentials.
  • Reloaded at startup, expired rows purged on load and on cleanup. Dashboard session bearers stay memory-only, named tokens are untouched.

Revocation

  • Deleting a connector from /app/connectors drops its OAuth tokens.
  • At startup, OAuth tokens whose client is no longer in clients.json are pruned (covers beaconmcp auth revoke run while the server is up). Skipped if no client loaded at all, so an unreadable clients.json cannot wipe the db.
  • security_end_session keeps its grace window for the bearer, and also drops the refresh family right away, so the client can't quietly mint a replacement.

Config

  • server.refresh_token_ttl (seconds, default 30 days, 0 = never expires), env BEACONMCP_REFRESH_TOKEN_TTL, wizard field, validate-config summary. Each refresh restarts the clock, so a client in regular use never signs in again.

Audit

  • auth.token.refresh on success, auth.token.refresh.reuse when a family gets revoked, auth.token.fail with reason=refresh_invalid|refresh_expired otherwise.
  • auth.token.issue is now also emitted for authorization_code (it was only emitted for client_credentials).

Docs: configuration, security, clients (ChatGPT / Le Chat no longer re-prompt every 24 h), yaml example.

Notes

  • Named tokens are still stored raw in named_tokens (the dashboard revokes them by prefix). Hashing them too is doable but out of scope here.
  • RFC 8707 resource is not tracked by the token endpoint today, so there is no audience to carry over on refresh.
  • The /oauth/authorize confirmation screen still shows "Access expires" at +24 h, which is now the access token only; the client renews it on its own. Could use a copy tweak in a follow-up.

Upgrade

No migration step: the new tables are created on first start and existing named tokens are kept. Access tokens issued by the previous version were never persisted, so clients connected before the upgrade have to sign in once after it; from then on restarts and the 24 h expiry are transparent.

Tests

  • New tests/test_oauth_refresh.py (26 tests): refresh OK, rotation, replay revokes the family (also across a restart), other families untouched, wrong client refused, unknown/expired refresh refused, configurable TTL and 0, persistence across a new TokenStore on the same db, hashed storage, expired rows purged, in-place upgrade of an old tokens.db, client revoke, end-session revoke, startup pruning, metadata, YAML parsing and wizard round-trip.
  • Full suite: 498 passed, 1 skipped (passkey extra not installed). ruff check clean on touched files.
  • Manual HTTP smoke test against the real Starlette app: refresh returns 200 with a new pair, bad secret 401, bearer still accepted after a rebuild on the same db, replay 400 then the family's next refresh 400 as well.

OAuth access tokens only lived in memory and no refresh token was issued,
so an authorization_code client like claude.ai fell back to "needs
authentication" on every restart and at least once every 24 h, forcing a
manual passkey/TOTP sign-in each time.

- /oauth/token returns a refresh_token on the authorization_code grant
  (never on client_credentials, which keeps its TOTP-on-every-exchange rule)
- new refresh_token grant: same client_secret_post check, rotates the
  refresh token and issues a new access token; replaying a rotated-out
  token revokes the whole family (OAuth 2.1 / RFC 9700 reuse detection)
- OAuth access and refresh tokens are persisted in tokens.db as SHA-256
  hashes (new oauth_tokens / refresh_tokens tables, created in place),
  reloaded at startup, expired rows purged; dashboard session bearers stay
  memory-only
- server.refresh_token_ttl (default 30 days, 0 = never expires), env
  BEACONMCP_REFRESH_TOKEN_TTL, wizard field and config summary
- refresh_token added to grant_types_supported (main and DCR metadata)
  and to the DCR registration response
- deleting a connector drops its OAuth tokens; tokens of clients missing
  from clients.json are pruned at startup; security_end_session also
  drops the refresh family
- audit: auth.token.refresh, auth.token.refresh.reuse, and
  auth.token.issue now also emitted for authorization_code
- docs: configuration, security, clients, yaml example

Tests: 498 passed.
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.

1 participant