Make login state single-use on the server
CI / ci (push) Successful in 9m33s

A saved copy of the signed login cookie verified until it expired, so the
callback accepted it again with a second valid authorization code. The
login now records its state as pending and the callback consumes it,
refusing a replay with the same 400 as a state mismatch.

The pending-state set moves from gcal into auth as PendingStates, so login
and the Google Calendar connect share one mechanism. It is in memory, so a
flow started before a restart fails closed after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 01:31:24 -04:00
co-authored by Claude Opus 5.5
parent 3a39fc8f2c
commit 0dccf87b06
4 changed files with 112 additions and 32 deletions
+13 -2
View File
@@ -79,6 +79,7 @@ type Auth struct {
provider *oidc.Provider
client *http.Client
cookie loginCookie
pending *PendingStates
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
@@ -157,6 +158,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
pending: NewPendingStates(loginCookieTTL),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
@@ -250,11 +252,13 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
Verifier: oauth2.GenerateVerifier(),
Return: returnTo,
}
if err := a.cookie.set(w, state, time.Now()); err != nil {
now := time.Now()
if err := a.cookie.set(w, state, now); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.pending.Add(state.State, now)
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
oidc.Nonce(state.Nonce),
oauth2.S256ChallengeOption(state.Verifier),
@@ -262,7 +266,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
@@ -283,6 +287,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !a.pending.Consume(state.State) {
a.logger.WarnContext(ctx, "login callback state already used")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.