Skip to content

Refresh account pages, speed up /account, and fix 2FA sign-in from OAuth - #175

Merged
haileyok merged 1 commit into
mainfrom
hailey/account-refresh
Sep 28, 2026
Merged

haileyok merged 1 commit into
mainfrom
hailey/account-refresh

Conversation

@haileyok

Copy link
Copy Markdown
Owner

Summary

  • Redesigns the account, sign-in, consent, and two-factor pages (light/dark, mobile-friendly), grouping OAuth sessions by app.
  • Makes /account fast for accounts with many sessions: it was loading every OAuth token ever issued and looking up client metadata once per token.
  • Makes 2FA sign-in from an OAuth flow its own step, so finishing it always returns to the app the user came from.

Changes

  • Account page speed
    • The token query's time filter was inverted (created_at < now + 2y matched every token), and the page then rendered all tokens. liveOauthTokens now reads only sessions that can still be refreshed (current session version, within the session and refresh lifetimes).
    • App names are resolved once per app, in parallel, capped at 2s, cached, and use metadata only (new ClientManager.GetMetadata, no JWKS fetch). Apps that don't answer are shown by hostname.
    • New hourly oauthTokenCleanupRoutine deletes tokens past every lifetime or from an old session version.
    • The client JWKS cache was never written to; it now is.
    • "Expires in" showed the maximum lifetime; it now shows the real remaining time.
  • Sign-in / OAuth
    • After the password step, the pending account, its session version, and the OAuth return query are stored in the signed session cookie for 10 minutes. /account/signin/verify asks only for the code, then redirects to /oauth/authorize?<query>. A password reset between the steps cancels the pending sign-in.
    • Only queries carrying client_id/request_uri are treated as an OAuth return (fixes "Sign in another account" from the dashboard, which previously bounced back to /account).
    • Viewing the consent page extends the authorization request's expiry, so slow 2FA doesn't kill it.
    • Signed-out consent POSTs go back to sign-in with the request instead of dropping it.
    • Reject redirects to redirect_uri with error=access_denied (previously client_uri). The redirect uses & when the URI already has a query.
    • Accept is conditional (sub IS NULL AND code IS NULL), so it only succeeds once. Expired or used requests render a readable page instead of JSON.
    • The consent page describes scopes in plain language (describeScopes), with raw scopes behind a toggle. It also pre-fills login_hint and names the app on the sign-in page.
  • Account page / revoke
    • Sessions are grouped by app with per-session and per-app sign out. Revoke takes the token's row id (or client_id) instead of the access token, which used to appear in the page HTML, and requires a same-origin POST.
  • Templates
    • Shared partials.html (head, header, flashes, icons) and a new stylesheet. pico.css and the unused alert.html are removed.
    • An initial template func and Hostname are injected by the renderer.

Validation

  • gofmt -l .: clean
  • go vet ./...: clean
  • CGO_ENABLED=1 go test -race ./...: all packages pass
  • New tests in server/signin_oauth_redirect_test.go: two-step 2FA from OAuth returns to /oauth/authorize, no-pending and session-version-change restarts, pending expiry, oauthReturnQuery, add-account from dashboard, live-token filtering, grouping by app, pruning, revoke scoping (other accounts' sessions and cross-site requests), reject → access_denied, signed-out POST keeps the request, accept-once, describeScopes.
  • Updated TestSigninPageTOTP for the new code-step redirect and TestAccountPageEscapesClientName for the new data shape (also asserts no javascript: links).
  • Rendered every page with seeded data in headless Chromium (light, dark, 390px mobile) and checked the screenshots.

Review notes

  • I couldn't reproduce the original 2FA redirect loss with a test against the old code; the old chain passed. The fix is structural: the return destination no longer depends on a hidden field surviving a re-rendered form.
  • The code step no longer requires re-entering the password. Guessing is still bounded by the existing 2FA lockout, and the pending state is tied to the signed cookie and the account's session version.
  • Revoke now requires Origin/Referer to match the host (same check as /account/switch).
  • Old cached sign-in pages that post username + password + token together still work (single-POST path kept).

… from OAuth

Account page
- Redesigned account, sign-in, consent, and two-factor pages with a single
  light/dark stylesheet (drops Pico).
- Sessions are grouped by app, most recently used first, with per-session and
  per-app sign out. Revoking uses the session's row ID instead of putting the
  access token in the page, and requires a same-origin POST.
- "Add another account" works while signed in.

Account page speed
- Only live sessions are loaded: the query filtered on created_at < now+2y
  (every token ever issued), and the page then showed all of them anyway.
- App names are looked up once per app, in parallel, with a 2s cap and a
  cache, using metadata only (no JWKS fetch).
- Tokens past every lifetime or from an old session version are pruned hourly.
- The client JWKS cache was never written; it now is.

Sign-in and OAuth
- A second factor is now its own step. After the password checks out, the
  pending account and OAuth return are kept in the session, so the code page
  asks only for the code and always returns to /oauth/authorize.
- Viewing the consent page keeps the OAuth request alive during slow 2FA.
- Signed-out consent POSTs return to sign-in with the request.
- Reject redirects to the client with error=access_denied.
- Accept only succeeds once; expired/used requests get a readable page.
- Scopes are described in plain language on the consent page.
@haileyok
haileyok marked this pull request as ready for review September 28, 2026 23:55
@haileyok
haileyok merged commit d9499d4 into main Sep 28, 2026
1 check 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