Wait for the identity provider's discovery document without blocking start

Third section of #141. start no longer fetches the identity provider's
discovery document before it serves. Setup returns a Config whose two
discovery values are empty, and Discover runs beside the server from
before the listener opens: it fetches the document, publishes the
verifier and then the sign-in endpoints, logs "identity provider
discovery succeeded; sign-in is available", and returns. Until it has,
/login and /callback answer a styled 503 "Sign-in unavailable" and
/register "Registration unavailable", each with Retry-After: 5 and a
"Try again" action, and a sign-in request wakes the loop's wait. A
document whose issuer is not the configured one, or that names no
end-session endpoint (or one that does not parse as an absolute URL),
publishes nothing that sign-in reads and is retried at the cap.

The two values sit behind two atomic.Pointers in auth.Config. The
sign-in value holds a pointer to the verifier value it was published
with, and is stored only after the verifier, from the same document, so
a request never pairs endpoints from one document with a verifier from
another. Re-derivation of roles reads the verifier alone, so it runs
while sign-in still waits for an end-session endpoint; POST /logout
before discovery ends the console's session, lands on /, and revokes
nothing. A Config built by hand, as most tests build theirs, reads its
exported fields as it did.

internal/retryloop is the loop both discovery and, in a later section,
the Temporal connection run: waits from 0.5 seconds doubling to a
15-second cap, plus up to half again of jitter; a wake ends a wait no
sooner than a second after the last attempt began; a failure marked
AtCap makes only the next wait's base the cap, and a wake does not end
that wait. Its log decay writes the first failure, a changed error at
most once per 30 seconds, otherwise one line per 5 minutes, Warn until
the escalation time and Error from it, with the line at the escalation
time written at once; every line's attempts is the attempts so far.
The htmx 5xx toast reads "Server error. Try again later."

Corrections to the change text, made while building: the wait after
an AtCap failure is the cap plus the jitter (the design's bound of
about 23 seconds after an outage already counted the jitter), and every
log line's attempts is the number of attempts so far (one meaning on
every line, matching elapsed), where the design said the cap alone and
the attempts since the last line.

internal/server/testdata/routes-not-ready.golden is new: it differs from
routes.golden in the four sign-in rows only. routes.golden and
route_pages.golden do not change.

Full suite on reset databases: 4422 pass, 15 skip, none failed. The console booted on the
test stack and logged the discovery success. go test -race -p 1 over
internal/retryloop, internal/auth and internal/server after
reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught:
- LoginHandler's gate after RenewToken
- the gate without the wake
- the gate answers through http.Error
- the gate answers 500 in place of 503
- the gate without Retry-After
- /register without the gate
- the sign-in value published from a document without an end-session endpoint
- the sign-in value stored outside the issuer check
- the verifier published only together with the sign-in value
- the verifier published from a document with another issuer
- an unusable document returned as a plain error
- the attempt on http.DefaultClient
- LogoutHandler before publication answers 500
- LogoutHandler before publication redirects without destroying the session
- LogoutHandler with the verifier and no end-session endpoint revokes nothing
- refreshIdentity ends the session when no verifier is published
- Run without the cap
- Run without the jitter
- a wake that ends an AtCap wait
- the wait ignores ctx.Done()
- the discovery holder is a plain field in place of an atomic.Pointer
- a Failure's level ignored
- the decay without its five-minute rule
- the escalation never reached
- the changed-text rule without its thirty-second floor
- a wake that ignores the one-second gap
- answer() without the Retry-After line
- re-derivation reads the exported fields in place of the holder
- a level Error line written at once whatever the last line
- an AtCap failure ratchets the doubling to the cap
- the discovery log lines without the issuer attribute
- the failure lines with a fixed attempts attribute
- an end-session endpoint that does not parse or is not absolute is accepted
- Run attempts once on a context that has ended
- a Failure returned by pointer drops its message and level
This commit is contained in:
2026-10-23 17:15:45 -04:00
parent 6b57598df6
commit d1151dd6fe
14 changed files with 2646 additions and 128 deletions
+153 -85
View File
@@ -19,6 +19,7 @@ import (
"net/url"
"slices"
"strings"
"sync/atomic"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
@@ -76,6 +77,12 @@ var (
signInFailed = authFailure{"Sign-in failed", "Sign in again", "/login"}
registrationFailed = authFailure{"Registration failed", "Start again", "/register"}
signOutFailed = authFailure{"Sign-out failed", "Back to the console", "/"}
// Sign-in and registration answer these while the identity provider's
// discovery document is not usable (design D2, copy rows 1 and 2). The
// action repeats the request.
signInUnavailable = authFailure{"Sign-in unavailable", "Try again", "/login"}
registrationUnavailable = authFailure{"Registration unavailable", "Try again", "/register"}
)
// retryableAuthorizationErrors are the identity provider's error codes that a
@@ -102,7 +109,13 @@ func authorizationFailure(code string) authFailure {
return signInFailed
}
// Config holds all auth-related configuration
// Config holds all auth-related configuration.
//
// A running console's Config comes from Setup, which builds it with
// NewAwaitingDiscovery: its discovery is two holders that Discover fills, and
// the exported discovery fields below stay empty. A Config built by hand, as
// most tests do, sets those fields instead, and the package reads them
// exactly as it always has (verifier, signIn).
type Config struct {
SessionManager *scs.SessionManager
OAuthConfig *oauth2.Config
@@ -119,37 +132,38 @@ type Config struct {
Failures FailurePage
// EndSessionEndpoint is the provider's RP-initiated logout endpoint
// (OpenID Connect RP-Initiated Logout 1.0, section 2.1), read from
// discovery at Setup. A provider that publishes none cannot end its
// session on the person's behalf, and Setup refuses to start rather
// than guess a path.
// (OpenID Connect RP-Initiated Logout 1.0, section 2.1) for a Config
// built by hand. A running console never sets it: Discover reads the
// endpoint from the discovery document and publishes it in the sign-in
// value, and a provider that publishes none keeps sign-in unavailable.
EndSessionEndpoint string
// RevocationEndpoint is the provider's token revocation endpoint (RFC
// 7009), read from discovery at Setup. Empty when the provider
// 7009) for a Config built by hand. A running console reads it from the
// discovery document into the verifier value. Empty when the provider
// publishes none; the refresh token is then not revoked at sign-out,
// which Setup says once at boot.
// which Discover says once when it publishes the verifier.
RevocationEndpoint string
// awaiting is true for a Config built by NewAwaitingDiscovery, whose
// verifier and sign-in values come from the holders below.
awaiting bool
// wake asks Discover to attempt now; its buffer is one.
wake chan struct{}
// What Discover needs from configuration, read once by Setup before the
// listener opens so that no later read of viper can race a write.
issuer string
clientID string
clientSecret string
redirectURL string
// refreshes serialises identity re-derivation per session, keyed by
// session token (refreshIdentity). Its zero value is ready to use.
refreshes singleflight.Group
}
// providerEndpoints reads the endpoints sign-out needs beyond the OAuth 2.0
// pair from the provider's discovery document.
func providerEndpoints(provider *oidc.Provider) (endSession, revocation string, err error) {
var meta struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
RevocationEndpoint string `json:"revocation_endpoint"`
}
if err := provider.Claims(&meta); err != nil {
return "", "", fmt.Errorf("reading the identity provider's discovery document: %w", err)
}
if meta.EndSessionEndpoint == "" {
return "", "", errors.New("the identity provider publishes no end_session_endpoint; sign-out needs OpenID Connect RP-Initiated Logout 1.0")
}
return meta.EndSessionEndpoint, meta.RevocationEndpoint, nil
// verifierValue and signInValue are the discovery holders (design D3).
verifierValue atomic.Pointer[discovered]
signInValue atomic.Pointer[signInState]
}
// showFailure answers a refused authentication flow with the styled page, or
@@ -162,7 +176,9 @@ func (c *Config) showFailure(w http.ResponseWriter, r *http.Request, status int,
http.Error(w, f.heading, status)
}
// Setup initializes the auth configuration
// Setup builds the auth configuration over the session store. It probes the
// store once and fetches nothing from the identity provider: the Config it
// returns has empty discovery holders, and Discover fills them (design D3).
func Setup(database *sql.DB, identityQ identity.Querier, orgQ organization.Querier) (*Config, error) {
ctx := context.Background()
logger := logging.FromContext(ctx)
@@ -193,43 +209,17 @@ func Setup(database *sql.DB, identityQ identity.Querier, orgQ organization.Queri
logger.Info("session store connected",
slog.String("addr", viper.GetString("valkey-addr")))
sessionManager := newSessionManager(redisstore.New(pool))
// Initialize OIDC provider
provider, err := oidc.NewProvider(ctx, viper.GetString("oidc-idp-issuer-url"))
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
}
endSession, revocation, err := providerEndpoints(provider)
if err != nil {
return nil, err
}
if revocation == "" {
logger.Warn("the identity provider publishes no revocation_endpoint; refresh tokens will not be revoked at sign-out")
}
// Create OAuth2 config
oauthConfig := &oauth2.Config{
ClientID: viper.GetString("oidc-sp-client-id"),
ClientSecret: viper.GetString("oidc-sp-client-secret"),
RedirectURL: viper.GetString("base-url") + "/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// Create auth config
config := &Config{
SessionManager: sessionManager,
OAuthConfig: oauthConfig,
Provider: provider,
Verifier: provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID}),
Database: database,
IdentityQ: identityQ,
OrgQ: orgQ,
EndSessionEndpoint: endSession,
RevocationEndpoint: revocation,
}
// Nothing is fetched from the identity provider here: Discover does that
// in the background, so the console serves the routes that need only the
// database before the provider answers (design D1, D3).
config := NewAwaitingDiscovery(newSessionManager(redisstore.New(pool)))
config.Database = database
config.IdentityQ = identityQ
config.OrgQ = orgQ
config.issuer = viper.GetString("oidc-idp-issuer-url")
config.clientID = viper.GetString("oidc-sp-client-id")
config.clientSecret = viper.GetString("oidc-sp-client-secret")
config.redirectURL = viper.GetString("base-url") + "/callback"
return config, nil
}
@@ -433,6 +423,14 @@ func (c *Config) endSession(ctx context.Context) {
// LoginHandler initiates the OIDC authentication flow
func (c *Config) LoginHandler(w http.ResponseWriter, r *http.Request) {
// Before anything touches the session: with no usable discovery document
// there is nothing to redirect to, and a rotated token would write a
// cookie for a sign-in that cannot start.
si := c.awaitSignIn(w, r, signInUnavailable)
if si == nil {
return
}
// Rotate the session token before writing OIDC flow data.
//
// Defends against two things:
@@ -488,7 +486,7 @@ func (c *Config) LoginHandler(w http.ResponseWriter, r *http.Request) {
hashVal := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hashVal[:])
authURL := c.OAuthConfig.AuthCodeURL(
authURL := si.verifier.oauth.AuthCodeURL(
state,
oidc.Nonce(nonce),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
@@ -502,6 +500,13 @@ func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := logging.FromContext(ctx)
// The pair this request uses comes from one published document.
si := c.awaitSignIn(w, r, signInUnavailable)
if si == nil {
return
}
v := si.verifier
// Validate state.
//
// A genuine mismatch (savedState present but different) stays a hard 400 —
@@ -557,7 +562,7 @@ func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
token, err := c.OAuthConfig.Exchange(
token, err := v.oauth.Exchange(
ctx,
code,
oauth2.VerifierOption(codeVerifier),
@@ -577,7 +582,7 @@ func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
idToken, err := c.Verifier.Verify(ctx, rawIDToken)
idToken, err := v.verifier.Verify(ctx, rawIDToken)
if err != nil {
logger.Warn("ID token failed verification",
slog.Any("error", err))
@@ -625,7 +630,7 @@ func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) {
}
// ---- Extract roles from the verified ID token (IdP-agnostic) ----
roles := rolesFromIDToken(ctx, idToken, c.OAuthConfig.ClientID, userInfoClaims.Email)
roles := rolesFromIDToken(ctx, idToken, v.oauth.ClientID, userInfoClaims.Email)
logger.Info("roles read from the ID token",
slog.String("email", userInfoClaims.Email),
slog.Any("roles", roles))
@@ -738,10 +743,20 @@ func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) {
return
}
endSessionURL, err := url.Parse(c.EndSessionEndpoint)
if err != nil || c.EndSessionEndpoint == "" {
logger.Error("no usable end_session_endpoint; Setup guarantees one, so this Config was built by hand",
slog.String("endpoint", c.EndSessionEndpoint),
// One load of each holder for the whole request: the sign-in value when
// it is published, whose verifier is the one it was published with, and
// the verifier value only when it is not (design D3).
si := c.signIn()
if si == nil {
c.logoutBeforeSignIn(w, r, c.verifier())
return
}
v := si.verifier
endSessionURL, err := url.Parse(si.endSession)
if err != nil || si.endSession == "" {
logger.Error("no usable end_session_endpoint; the sign-in value always carries one, so this Config was built by hand",
slog.String("endpoint", si.endSession),
slog.Any("error", err))
c.showFailure(w, r, http.StatusInternalServerError, signOutFailed)
return
@@ -750,7 +765,7 @@ func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) {
// Everything the request needs from the session is read here, before
// the session goes.
storedRefreshToken := c.SessionManager.GetString(ctx, sessionKeyRefreshToken)
hint, currentRefreshToken := c.idTokenHint(ctx, storedRefreshToken)
hint, currentRefreshToken := c.idTokenHint(ctx, v, storedRefreshToken)
// An unguessable state, echoed back to /logout-callback by the provider
// (OpenID Connect RP-Initiated Logout 1.0, section 2), is what lets that
@@ -786,7 +801,7 @@ func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) {
return
}
c.revokeRefreshTokens(ctx, storedRefreshToken, currentRefreshToken)
c.revokeRefreshTokens(ctx, v, storedRefreshToken, currentRefreshToken)
// Log the destination without its query string. The query carries
// id_token_hint, a live token; logging the whole URL wrote it to the log
@@ -811,6 +826,51 @@ func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, endSessionURL.String(), http.StatusSeeOther)
}
// logoutBeforeSignIn is POST /logout while the end-session endpoint is not
// published (design D5). The console session ends, which the console can
// always do; the refresh tokens are revoked when the verifier is published and
// its document carries a revocation endpoint, and nothing is revoked when v is
// nil. No ID token hint is minted. The person lands on the console's root; the
// identity provider's own session stays, as it does for a person who never
// completes the provider's round trip.
func (c *Config) logoutBeforeSignIn(w http.ResponseWriter, r *http.Request, v *discovered) {
ctx := r.Context()
logger := logging.FromContext(ctx)
storedRefreshToken := c.SessionManager.GetString(ctx, sessionKeyRefreshToken)
if err := c.SessionManager.Destroy(ctx); err != nil {
logger.Error("could not end the session at sign-out",
slog.Any("error", err))
c.showFailure(w, r, http.StatusInternalServerError, signOutFailed)
return
}
c.revokeRefreshTokens(ctx, v, storedRefreshToken)
logger.Info("session ended; the identity provider's session was not ended because its end-session endpoint is not known")
w.Header().Add("Vary", "HX-Request")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// awaitSignIn returns the sign-in value, or answers the request and returns
// nil when none is published (design D2, D3). The answer is 503 with
// Retry-After: 5 through showFailure with f, and the discovery loop is woken
// first so that the next request meets a fresh attempt.
func (c *Config) awaitSignIn(w http.ResponseWriter, r *http.Request, f authFailure) *signInState {
si := c.signIn()
if si != nil {
return si
}
c.wakeDiscovery()
w.Header().Set("Retry-After", "5")
c.showFailure(w, r, http.StatusServiceUnavailable, f)
return nil
}
// logoutGet answers a GET at /logout, which is never the account menu: it is
// an old bookmark, a typed address, or a link on some other page. None of
// those may end a session, so the browser is sent to the console's root,
@@ -832,11 +892,11 @@ func (c *Config) logoutGet(w http.ResponseWriter, r *http.Request) {
// A provider may rotate the refresh token on use (RFC 6749 section 6). The
// one returned is whichever is live afterwards, so the caller revokes the
// right one.
func (c *Config) idTokenHint(ctx context.Context, refreshToken string) (hint, currentRefreshToken string) {
func (c *Config) idTokenHint(ctx context.Context, v *discovered, refreshToken string) (hint, currentRefreshToken string) {
if stored := c.SessionManager.GetString(ctx, sessionKeyIDToken); stored != "" && idTokenUsableAsHint(stored, time.Now()) {
return stored, refreshToken
}
if refreshToken == "" || c.OAuthConfig == nil {
if refreshToken == "" || v == nil || v.oauth == nil {
return "", refreshToken
}
@@ -845,7 +905,7 @@ func (c *Config) idTokenHint(ctx context.Context, refreshToken string) (hint, cu
defer cancel()
// A token with no access token is not Valid, so the source refreshes on
// the first call rather than returning what it was given.
fresh, err := c.OAuthConfig.TokenSource(refreshCtx, &oauth2.Token{RefreshToken: refreshToken}).Token()
fresh, err := v.oauth.TokenSource(refreshCtx, &oauth2.Token{RefreshToken: refreshToken}).Token()
if err != nil {
logger.Warn("could not refresh the ID token for the sign-out hint; the identity provider will ask for confirmation",
slog.Any("error", err))
@@ -878,15 +938,15 @@ func (c *Config) idTokenHint(ctx context.Context, refreshToken string) (hint, cu
// token named and its access tokens, not other refresh tokens of the same
// grant, so a provider that hands out a new refresh token without retiring
// the old one would otherwise leave the old one working.
func (c *Config) revokeRefreshTokens(ctx context.Context, refreshTokens ...string) {
if c.RevocationEndpoint == "" || c.OAuthConfig == nil {
func (c *Config) revokeRefreshTokens(ctx context.Context, v *discovered, refreshTokens ...string) {
if v == nil || v.revocation == "" || v.oauth == nil {
return
}
seen := map[string]bool{"": true}
for _, token := range refreshTokens {
if !seen[token] {
seen[token] = true
c.revokeRefreshToken(ctx, token)
c.revokeRefreshToken(ctx, v, token)
}
}
}
@@ -896,20 +956,20 @@ func (c *Config) revokeRefreshTokens(ctx context.Context, refreshTokens ...strin
// SHOULD use, with the id and secret form-encoded as that section requires. A
// provider answers 200 whether or not the token was live (RFC 7009 section
// 2.2), so anything else is worth a line.
func (c *Config) revokeRefreshToken(ctx context.Context, refreshToken string) {
func (c *Config) revokeRefreshToken(ctx context.Context, v *discovered, refreshToken string) {
logger := logging.FromContext(ctx)
revokeCtx, cancel := context.WithTimeout(ctx, providerCallTimeout)
defer cancel()
form := url.Values{"token": {refreshToken}, "token_type_hint": {"refresh_token"}}
req, err := http.NewRequestWithContext(revokeCtx, http.MethodPost, c.RevocationEndpoint, strings.NewReader(form.Encode()))
req, err := http.NewRequestWithContext(revokeCtx, http.MethodPost, v.revocation, strings.NewReader(form.Encode()))
if err != nil {
logger.Warn("could not build the refresh token revocation request",
slog.Any("error", err))
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(c.OAuthConfig.ClientID), url.QueryEscape(c.OAuthConfig.ClientSecret))
req.SetBasicAuth(url.QueryEscape(v.oauth.ClientID), url.QueryEscape(v.oauth.ClientSecret))
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -987,6 +1047,12 @@ func (c *Config) RegistrationHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := logging.FromContext(ctx)
// Registration redirects to the provider without its discovery document,
// but the flow ends at /callback, which needs it (design F28).
if c.awaitSignIn(w, r, registrationUnavailable) == nil {
return
}
// Rotate the session id before the flow starts, exactly as LoginHandler
// does. Registration is the other path that ends in an authenticated
// session, and it was the only one that did not rotate: a session id
@@ -1103,7 +1169,8 @@ func (c *Config) refreshIdentity(ctx context.Context) bool {
return true
}
refreshToken := c.SessionManager.GetString(ctx, sessionKeyRefreshToken)
if refreshToken == "" || c.OAuthConfig == nil || c.Verifier == nil {
v := c.verifier()
if refreshToken == "" || v == nil || v.oauth == nil || v.verifier == nil {
return true
}
logger := logging.FromContext(ctx)
@@ -1114,7 +1181,7 @@ func (c *Config) refreshIdentity(ctx context.Context) bool {
// as a replay, which reads as a definitive refusal below. Requests that
// arrive while one is in flight share its answer.
shared, _, _ := c.refreshes.Do(c.SessionManager.Token(ctx), func() (any, error) {
return c.rederive(ctx, refreshToken), nil
return c.rederive(ctx, v, refreshToken), nil
})
answer := shared.(identityRefresh)
@@ -1155,10 +1222,11 @@ func (c *Config) refreshIdentity(ctx context.Context) bool {
// rederive is the one call to the provider: a refresh-token grant, the new ID
// token verified exactly as sign-in verifies one, and the roles read from it.
func (c *Config) rederive(ctx context.Context, refreshToken string) identityRefresh {
// v is the verifier value refreshIdentity loaded for this request.
func (c *Config) rederive(ctx context.Context, v *discovered, refreshToken string) identityRefresh {
callCtx, cancel := context.WithTimeout(ctx, providerCallTimeout)
defer cancel()
fresh, err := c.OAuthConfig.TokenSource(callCtx, &oauth2.Token{RefreshToken: refreshToken}).Token()
fresh, err := v.oauth.TokenSource(callCtx, &oauth2.Token{RefreshToken: refreshToken}).Token()
if err != nil {
var refusal *oauth2.RetrieveError
if errors.As(err, &refusal) && refusal.ErrorCode == "invalid_grant" {
@@ -1170,7 +1238,7 @@ func (c *Config) rederive(ctx context.Context, refreshToken string) identityRefr
if rawIDToken == "" {
return identityRefresh{err: errors.New("the refresh response carries no ID token")}
}
idToken, err := c.Verifier.Verify(callCtx, rawIDToken)
idToken, err := v.verifier.Verify(callCtx, rawIDToken)
if err != nil {
return identityRefresh{err: fmt.Errorf("verifying the refreshed ID token: %w", err)}
}
@@ -1182,7 +1250,7 @@ func (c *Config) rederive(ctx context.Context, refreshToken string) identityRefr
idToken: rawIDToken,
refreshToken: fresh.RefreshToken,
subject: idToken.Subject,
roles: rolesFromIDToken(ctx, idToken, c.OAuthConfig.ClientID, claims.Email),
roles: rolesFromIDToken(ctx, idToken, v.oauth.ClientID, claims.Email),
}
}
+2 -1
View File
@@ -47,7 +47,8 @@ func (failingDirectory) GetPersonByID(context.Context, string) (identity.Person,
// signIn runs one request through LoadAndSave that marks the session
// authenticated for personID, the way CallbackHandler does, and returns the
// session cookie it set.
// session cookie it set. The Config's method of the same name is a different
// identifier: a method name is not in package scope.
func signIn(t *testing.T, cfg *Config, personID string) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
+249
View File
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/alexedwards/scs/v2"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/retryloop"
)
// A Config built by NewAwaitingDiscovery fills two holders while the console
// runs (design D3). Each holds an immutable value that the discovery loop
// builds completely before it stores it, once, and never replaces; a request
// loads each pointer it needs once and uses that one value for the whole
// request.
// discovered is what the identity provider's discovery document gives a
// session that is already signed in: the OAuth configuration, the ID token
// verifier, the provider, and the revocation endpoint. It is published when
// the document's issuer is the configured one.
type discovered struct {
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
revocation string
}
// signInState is what sign-in needs besides the verifier: the end-session
// endpoint, and the verifier value it was published with, so that a request
// that loads this value never meets a verifier from another document.
type signInState struct {
verifier *discovered
endSession string
}
// discoveryAttemptTimeout bounds one discovery attempt: the document request
// and, through the dedicated client, every later fetch of the provider's key
// set. A test shortens it.
var discoveryAttemptTimeout = 10 * time.Second
// discoveryClock is the clock Discover hands to the retry loop. Nil means the
// real one; a test sets it to wait out a backoff without waiting.
var discoveryClock retryloop.Clock
// discoveryEscalation is the time after which a failed discovery logs at Error.
const discoveryEscalation = 90 * time.Second
// NewAwaitingDiscovery returns a Config over sessionManager whose discovery
// holders are empty. Discover fills them; until it has, the sign-in routes
// answer 503 (design D3). Setup builds its Config with it.
func NewAwaitingDiscovery(sessionManager *scs.SessionManager) *Config {
return &Config{
SessionManager: sessionManager,
awaiting: true,
wake: make(chan struct{}, 1),
}
}
// verifier returns the verifier value, which is nil for a Config built by
// NewAwaitingDiscovery until the loop publishes it. For a Config without
// holders (every hand-built one) it always returns a value that copies the
// exported fields as they are, a nil OAuthConfig and a nil Verifier included.
// The method is named for the holder it reads; the exported fields stay the
// way a hand-built Config sets what the holder would hold.
func (c *Config) verifier() *discovered {
if c.awaiting {
return c.verifierValue.Load()
}
return &discovered{oauth: c.OAuthConfig, verifier: c.Verifier, provider: c.Provider, revocation: c.RevocationEndpoint}
}
// signIn returns the sign-in value, which is nil for a Config built by
// NewAwaitingDiscovery until the loop publishes it. For a Config without
// holders it always returns a value, copying the exported fields as they are,
// an empty EndSessionEndpoint included. It is a method and not a function
// named signIn because the package's tests already have a package-level
// function of that name, and a method name is not in package scope.
func (c *Config) signIn() *signInState {
if c.awaiting {
return c.signInValue.Load()
}
return &signInState{verifier: c.verifier(), endSession: c.EndSessionEndpoint}
}
// wakeDiscovery asks the loop to attempt now. It never blocks: one wake
// waiting is as good as many.
func (c *Config) wakeDiscovery() {
select {
case c.wake <- struct{}{}:
default:
}
}
// issuerMismatchError is a discovery document whose issuer is not the
// configured one. Nothing in such a document is trusted, whatever else it
// carries.
type issuerMismatchError struct {
configured string
document string
}
func (e *issuerMismatchError) Error() string {
return fmt.Sprintf("the identity provider's discovery document names the issuer %q, not the configured %q", e.document, e.configured)
}
// errNoEndSession is a matching document that publishes no
// end_session_endpoint, which sign-out needs (OpenID Connect RP-Initiated
// Logout 1.0, section 2.1).
var errNoEndSession = errors.New("the identity provider publishes no end_session_endpoint; sign-out needs OpenID Connect RP-Initiated Logout 1.0")
// providerEndpoints reads the endpoints sign-out needs beyond the OAuth 2.0
// pair from the provider's discovery document, after checking that the
// document's issuer claim is the configured issuer. A document that names
// another issuer gives an *issuerMismatchError and no endpoint. A matching
// document with no end-session endpoint, or one that is not an absolute URL,
// gives errNoEndSession (wrapped, in the second case) together with the
// revocation endpoint, which the verifier needs.
func providerEndpoints(provider *oidc.Provider, issuer string) (endSession, revocation string, err error) {
var meta struct {
Issuer string `json:"issuer"`
EndSessionEndpoint string `json:"end_session_endpoint"`
RevocationEndpoint string `json:"revocation_endpoint"`
}
if err := provider.Claims(&meta); err != nil {
return "", "", fmt.Errorf("reading the identity provider's discovery document: %w", err)
}
if meta.Issuer != issuer {
return "", "", &issuerMismatchError{configured: issuer, document: meta.Issuer}
}
if meta.EndSessionEndpoint == "" {
return "", meta.RevocationEndpoint, errNoEndSession
}
if u, err := url.Parse(meta.EndSessionEndpoint); err != nil || !u.IsAbs() {
return "", meta.RevocationEndpoint, fmt.Errorf("%w: %q is not an absolute URL", errNoEndSession, meta.EndSessionEndpoint)
}
return meta.EndSessionEndpoint, meta.RevocationEndpoint, nil
}
// Discover fetches the identity provider's discovery document until both
// holders are published or ctx ends (design D3, D4, D17). The first attempt
// whose issuer matches publishes the verifier; the first such attempt whose
// document also carries an end_session_endpoint publishes the sign-in value,
// pointing at the stored verifier, so the verifier can come from one document
// and the endpoint from a later one. A document that cannot be used is
// retried at the backoff cap and logged at Error. A request to a sign-in route
// wakes the loop. Discover returns when ctx ends or both values are stored.
func (c *Config) Discover(ctx context.Context) {
logger := logging.FromContext(ctx)
policy := retryloop.Policy{
Escalation: discoveryEscalation,
Wake: c.wake,
FailureMessage: "identity provider discovery failed; sign-in is unavailable",
SuccessMessage: "identity provider discovery succeeded; sign-in is available",
Attrs: []slog.Attr{slog.String("issuer", c.issuer)},
Logger: logger,
Clock: discoveryClock,
}
// The loop ends with nil once both values are stored and with ctx's error
// when the process stops; neither is a failure to report.
_ = retryloop.Run(ctx, policy, c.discoverOnce)
}
// discoverOnce is one attempt: it builds the provider under a dedicated HTTP
// client and a deadline of the same length, and stores only inside the
// matching-issuer branch.
func (c *Config) discoverOnce(ctx context.Context) error {
client := &http.Client{Timeout: discoveryAttemptTimeout}
ctx, cancel := context.WithTimeout(oidc.ClientContext(ctx, client), discoveryAttemptTimeout)
defer cancel()
// The provider checks the document's issuer against the configured one
// itself unless told not to, and fails with an untyped error. It is told
// not to, so providerEndpoints reports the mismatch as a typed error; ID
// tokens are still checked against the configured issuer.
provider, err := oidc.NewProvider(oidc.InsecureIssuerURLContext(ctx, c.issuer), c.issuer)
if err != nil {
return err
}
endSession, revocation, err := providerEndpoints(provider, c.issuer)
var mismatch *issuerMismatchError
switch {
case errors.As(err, &mismatch):
return unusableDocument(err)
case err != nil && !errors.Is(err, errNoEndSession):
return err
}
c.publishVerifier(ctx, provider, revocation)
if err != nil {
return unusableDocument(err)
}
c.publishSignIn(endSession)
return nil
}
// unusableDocument is the failure of a document the loop cannot use: copy 10's
// message at Error from the first line, retried at the cap.
func unusableDocument(err error) error {
return retryloop.AtCap(retryloop.Failure{
Message: "identity provider discovery document cannot be used; sign-in is unavailable",
Level: slog.LevelError,
Err: err,
})
}
// publishVerifier stores the verifier value once, from a document whose
// issuer matches, and says once that no refresh token can be revoked when the
// document carries no revocation endpoint.
func (c *Config) publishVerifier(ctx context.Context, provider *oidc.Provider, revocation string) {
if c.verifierValue.Load() != nil {
return
}
if revocation == "" {
logging.FromContext(ctx).Warn("the identity provider publishes no revocation_endpoint; refresh tokens will not be revoked at sign-out")
}
oauthConfig := &oauth2.Config{
ClientID: c.clientID,
ClientSecret: c.clientSecret,
RedirectURL: c.redirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
c.verifierValue.Store(&discovered{
oauth: oauthConfig,
verifier: provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID}),
provider: provider,
revocation: revocation,
})
}
// publishSignIn stores the sign-in value once, after the verifier and pointing
// at the stored verifier.
func (c *Config) publishSignIn(endSession string) {
if c.signInValue.Load() != nil {
return
}
c.signInValue.Store(&signInState{verifier: c.verifierValue.Load(), endSession: endSession})
}
+848
View File
@@ -0,0 +1,848 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"github.com/alexedwards/scs/v2/memstore"
"github.com/go-jose/go-jose/v4"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/retryloop"
)
// These tests drive the discovery loop against an identity provider whose
// answer the test switches (design D3, D4, D5, D17). The helpers are prefixed
// discoverytest; they serve the fields of logout_test.go's fakeDiscovery document
// from one server that also answers the token, revocation and key set
// requests a signed-in session needs.
// discoverytestWait is how long a test waits for something the loop or a
// handler does on another goroutine; a test that reaches it fails naming what
// it waited for.
const discoverytestWait = 10 * time.Second
const (
copy9 = "identity provider discovery failed; sign-in is unavailable"
copy10 = "identity provider discovery document cannot be used; sign-in is unavailable"
copy11 = "identity provider discovery succeeded; sign-in is available"
copy20 = "session ended; the identity provider's session was not ended because its end-session endpoint is not known"
)
// discoverytestWaitFor polls cond until it holds or the deadline passes.
func discoverytestWaitFor(t *testing.T, what string, within time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(within)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("waited %v for %s", within, what)
}
time.Sleep(10 * time.Millisecond)
}
}
// discoverytestIdP is the identity provider: a discovery document, a key set,
// a token endpoint that answers a refresh grant with an ID token, and a
// revocation endpoint.
type discoverytestIdP struct {
srv *httptest.Server
done chan struct{} // closed at cleanup; releases hanging handlers
requests atomic.Int32
mu sync.Mutex
status int // when not zero, the document request answers it
hangDocument bool
hangKeys bool
issuer string // the document's issuer; empty means the server's own URL
extra map[string]any
idToken string
revoked []string
}
func newDiscoverytestIdP(t *testing.T) *discoverytestIdP {
t.Helper()
idp := &discoverytestIdP{done: make(chan struct{}), status: http.StatusServiceUnavailable}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", idp.document)
mux.HandleFunc("/keys", idp.keys)
mux.HandleFunc("/token", idp.token)
mux.HandleFunc("/revoke", idp.revoke)
idp.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idp.requests.Add(1)
mux.ServeHTTP(w, r)
}))
t.Cleanup(idp.srv.Close)
t.Cleanup(func() { close(idp.done) })
return idp
}
func (i *discoverytestIdP) url() string { return i.srv.URL }
// hold blocks until the client gives up or the test ends.
func (i *discoverytestIdP) hold(r *http.Request) {
select {
case <-r.Context().Done():
case <-i.done:
}
}
func (i *discoverytestIdP) document(w http.ResponseWriter, r *http.Request) {
i.mu.Lock()
status, hang, issuer := i.status, i.hangDocument, i.issuer
extra := make(map[string]any, len(i.extra))
for k, v := range i.extra {
extra[k] = v
}
i.mu.Unlock()
if hang {
i.hold(r)
return
}
if status != 0 {
w.WriteHeader(status)
return
}
if issuer == "" {
issuer = i.srv.URL
}
doc := map[string]any{
"issuer": issuer,
"authorization_endpoint": i.srv.URL + "/authorize",
"token_endpoint": i.srv.URL + "/token",
"jwks_uri": i.srv.URL + "/keys",
}
for k, v := range extra {
doc[k] = v
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc) // the client under test reads this body
}
func (i *discoverytestIdP) keys(w http.ResponseWriter, r *http.Request) {
i.mu.Lock()
hang := i.hangKeys
i.mu.Unlock()
if hang {
i.hold(r)
return
}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &signingKey.PublicKey, Algorithm: "RS256", Use: "sig"}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(set) // the client under test reads this body
}
func (i *discoverytestIdP) token(w http.ResponseWriter, _ *http.Request) {
i.mu.Lock()
idToken := i.idToken
i.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ // the client under test reads this body
"access_token": "fresh-access",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rotated-refresh",
"id_token": idToken,
})
}
func (i *discoverytestIdP) revoke(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
i.mu.Lock()
i.revoked = append(i.revoked, r.PostForm.Get("token"))
i.mu.Unlock()
}
func (i *discoverytestIdP) revokedTokens() []string {
i.mu.Lock()
defer i.mu.Unlock()
return slices.Clone(i.revoked)
}
// usable switches to a document the console can use: its own issuer, an
// end-session endpoint and a revocation endpoint.
func (i *discoverytestIdP) usable() {
i.mu.Lock()
defer i.mu.Unlock()
i.status, i.issuer = 0, ""
i.extra = map[string]any{"end_session_endpoint": i.srv.URL + "/logout", "revocation_endpoint": i.srv.URL + "/revoke"}
}
// withoutEndSession switches to a document with the right issuer and a
// revocation endpoint but no end_session_endpoint.
func (i *discoverytestIdP) withoutEndSession() {
i.mu.Lock()
defer i.mu.Unlock()
i.status, i.issuer = 0, ""
i.extra = map[string]any{"revocation_endpoint": i.srv.URL + "/revoke"}
}
// anotherIssuer switches to a document that names another issuer and carries
// every endpoint.
func (i *discoverytestIdP) anotherIssuer() {
i.mu.Lock()
defer i.mu.Unlock()
i.status, i.issuer = 0, "https://other.example/realms/main"
i.extra = map[string]any{"end_session_endpoint": i.srv.URL + "/logout", "revocation_endpoint": i.srv.URL + "/revoke"}
}
// endSessionAt switches to a document with the right issuer whose
// end_session_endpoint is endpoint, whatever it is.
func (i *discoverytestIdP) endSessionAt(endpoint string) {
i.mu.Lock()
defer i.mu.Unlock()
i.status, i.issuer = 0, ""
i.extra = map[string]any{"end_session_endpoint": endpoint, "revocation_endpoint": i.srv.URL + "/revoke"}
}
func (i *discoverytestIdP) hangDocuments() {
i.mu.Lock()
defer i.mu.Unlock()
i.hangDocument = true
}
func (i *discoverytestIdP) hangKeySet() {
i.mu.Lock()
defer i.mu.Unlock()
i.hangKeys = true
}
// idTokenFor signs an ID token the provider's own issuer vouches for.
func (i *discoverytestIdP) idTokenFor(t *testing.T, roles ...string) string {
t.Helper()
if roles == nil {
roles = []string{}
}
return signIDToken(t, map[string]any{
"iss": i.srv.URL,
"sub": refreshSubject,
"aud": "test-client",
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"email": "bob@example.test",
"roles": roles,
})
}
// rolesAtTokenEndpoint sets the roles the next refresh grant's ID token carries.
func (i *discoverytestIdP) rolesAtTokenEndpoint(t *testing.T, roles ...string) {
t.Helper()
token := i.idTokenFor(t, roles...)
i.mu.Lock()
defer i.mu.Unlock()
i.idToken = token
}
// discoverytestLogs keeps what a captured slog handler was given.
type discoverytestLogs struct {
mu sync.Mutex
records []discoverytestLine
}
type discoverytestLine struct {
level slog.Level
message string
attrs map[string]slog.Value
}
func (l *discoverytestLogs) Enabled(context.Context, slog.Level) bool { return true }
func (l *discoverytestLogs) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *discoverytestLogs) WithGroup(string) slog.Handler { return l }
func (l *discoverytestLogs) Handle(_ context.Context, r slog.Record) error {
line := discoverytestLine{level: r.Level, message: r.Message, attrs: map[string]slog.Value{}}
r.Attrs(func(a slog.Attr) bool {
line.attrs[a.Key] = a.Value
return true
})
l.mu.Lock()
defer l.mu.Unlock()
l.records = append(l.records, line)
return nil
}
// withMessage returns the lines written with this exact message.
func (l *discoverytestLogs) withMessage(message string) []discoverytestLine {
l.mu.Lock()
defer l.mu.Unlock()
var out []discoverytestLine
for _, r := range l.records {
if r.message == message {
out = append(out, r)
}
}
return out
}
// discoverytestClock is the loop's clock: a timer is announced on timers and
// fires when the test says so, moving the clock by its duration.
type discoverytestClock struct {
mu sync.Mutex
now time.Time
timers chan *discoverytestTimer
}
type discoverytestTimer struct {
clock *discoverytestClock
c chan time.Time
d time.Duration
}
func newDiscoverytestClock() *discoverytestClock {
return &discoverytestClock{now: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), timers: make(chan *discoverytestTimer, 64)}
}
func (c *discoverytestClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *discoverytestClock) NewTimer(d time.Duration) retryloop.Timer {
t := &discoverytestTimer{clock: c, c: make(chan time.Time, 1), d: d}
c.timers <- t
return t
}
func (t *discoverytestTimer) C() <-chan time.Time { return t.c }
func (t *discoverytestTimer) Stop() bool { return true }
func (t *discoverytestTimer) fire() {
t.clock.mu.Lock()
t.clock.now = t.clock.now.Add(t.d)
now := t.clock.now
t.clock.mu.Unlock()
t.c <- now
}
// next waits for the loop to announce its next wait.
func (c *discoverytestClock) next(t *testing.T, what string) *discoverytestTimer {
t.Helper()
select {
case timer := <-c.timers:
return timer
case <-time.After(discoverytestWait):
t.Fatalf("waited %v for %s", discoverytestWait, what)
return nil
}
}
// discoverytestHarness is a Config built by NewAwaitingDiscovery over the
// fake provider, with the session store the test keeps its own reference to.
type discoverytestHarness struct {
t *testing.T
idp *discoverytestIdP
cfg *Config
store *memstore.MemStore
logs *discoverytestLogs
// finished is closed when Discover has returned; start makes it.
finished chan struct{}
}
func newDiscoverytestHarness(t *testing.T) *discoverytestHarness {
t.Helper()
idp := newDiscoverytestIdP(t)
store := memstore.New()
sm := scs.New()
sm.Store = store
cfg := NewAwaitingDiscovery(sm)
cfg.issuer = idp.url()
cfg.clientID = "test-client"
cfg.clientSecret = "test-secret"
cfg.redirectURL = "http://console.test/callback"
return &discoverytestHarness{t: t, idp: idp, cfg: cfg, store: store, logs: &discoverytestLogs{}}
}
// start runs the discovery loop on a goroutine until the test ends. A nil
// clock is the real one. The package variables it sets are restored after the
// goroutine has returned.
func (h *discoverytestHarness) start(clock retryloop.Clock) {
h.t.Helper()
prev := discoveryClock
discoveryClock = clock
ctx, cancel := context.WithCancel(logging.WithContext(h.t.Context(), slog.New(h.logs)))
finished := make(chan struct{})
h.finished = finished
go func() {
defer close(finished)
h.cfg.Discover(ctx)
}()
h.t.Cleanup(func() {
cancel()
<-finished
discoveryClock = prev
})
}
// do serves one request through the session layer with the captured logger.
func (h *discoverytestHarness) do(handler http.Handler, req *http.Request) *httptest.ResponseRecorder {
req = req.WithContext(logging.WithContext(req.Context(), slog.New(h.logs)))
rec := httptest.NewRecorder()
h.cfg.SessionManager.LoadAndSave(handler).ServeHTTP(rec, req)
return rec
}
func (h *discoverytestHarness) login() *httptest.ResponseRecorder {
return h.do(http.HandlerFunc(h.cfg.LoginHandler), httptest.NewRequest(http.MethodGet, "/login", nil))
}
func (h *discoverytestHarness) published() bool { return h.cfg.signIn() != nil }
func (h *discoverytestHarness) verifierPublished() bool { return h.cfg.verifier() != nil }
// setViper sets a key for the test's lifetime.
func setViper(t *testing.T, key string, value any) {
t.Helper()
old, had := viper.Get(key), viper.IsSet(key)
viper.Set(key, value)
t.Cleanup(func() {
if had {
viper.Set(key, old)
} else {
viper.Set(key, nil)
}
})
}
func TestSignInWaitsForDiscovery(t *testing.T) {
h := newDiscoverytestHarness(t)
setViper(t, "oidc-idp-issuer-url", h.idp.url())
setViper(t, "oidc-sp-client-id", "test-client")
setViper(t, "base-url", "http://console.test")
h.start(nil)
rec := h.login()
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("/login before discovery: status = %d, want 503", rec.Code)
}
if got := rec.Header().Get("Retry-After"); got != "5" {
t.Errorf("/login before discovery: Retry-After = %q, want \"5\"", got)
}
if got := strings.TrimSpace(rec.Body.String()); got != "Sign-in unavailable" {
t.Errorf("/login before discovery: body = %q, want copy 1's heading", got)
}
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
t.Errorf("/login before discovery wrote a Set-Cookie: %v", got)
}
if entries, _ := h.store.All(); len(entries) != 0 {
t.Errorf("/login before discovery left %d entries in the session store, want 0", len(entries))
}
callback := h.do(http.HandlerFunc(h.cfg.CallbackHandler), httptest.NewRequest(http.MethodGet, "/callback", nil))
if callback.Code != http.StatusServiceUnavailable || strings.TrimSpace(callback.Body.String()) != "Sign-in unavailable" {
t.Errorf("/callback before discovery: %d %q, want 503 with copy 1", callback.Code, strings.TrimSpace(callback.Body.String()))
}
register := h.do(http.HandlerFunc(h.cfg.RegistrationHandler), httptest.NewRequest(http.MethodGet, "/register", nil))
if register.Code != http.StatusServiceUnavailable || strings.TrimSpace(register.Body.String()) != "Registration unavailable" {
t.Errorf("/register before discovery: %d %q, want 503 with copy 2", register.Code, strings.TrimSpace(register.Body.String()))
}
// The switch needs no new Config; a /login within the loop's wait wakes it
// and meets the usable document.
h.idp.usable()
var last *httptest.ResponseRecorder
discoverytestWaitFor(t, "/login to answer 302 after the provider switched to a usable document", discoverytestWait, func() bool {
last = h.login()
return last.Code == http.StatusFound
})
if got, want := last.Header().Get("Location"), h.idp.url()+"/authorize"; !strings.HasPrefix(got, want) {
t.Errorf("/login redirects to %q, want the provider's authorization endpoint %q", got, want)
}
}
// nextOrEnd waits for the loop's next wait; it returns nil when Discover has
// returned instead.
func (h *discoverytestHarness) nextOrEnd(t *testing.T, clock *discoverytestClock, what string) *discoverytestTimer {
t.Helper()
select {
case timer := <-clock.timers:
return timer
case <-h.finished:
return nil
case <-time.After(discoverytestWait):
t.Fatalf("waited %v for %s", discoverytestWait, what)
return nil
}
}
// discoverytestUnusableRow is one document the loop must keep out.
type discoverytestUnusableRow struct {
name string
configure func(*discoverytestIdP)
verifier bool // the verifier is published from this document
check func(*testing.T, error)
}
func checkNoEndSessionError(t *testing.T, err error) {
t.Helper()
if !errors.Is(err, errNoEndSession) {
t.Errorf("logged error = %v, want the missing end-session endpoint error", err)
}
}
func checkIssuerMismatchError(t *testing.T, err error) {
t.Helper()
var mismatch *issuerMismatchError
if !errors.As(err, &mismatch) {
t.Errorf("logged error = %v (%T), want an *issuerMismatchError", err, err)
}
}
func runUnusableRow(t *testing.T, row discoverytestUnusableRow) {
h := newDiscoverytestHarness(t)
row.configure(h.idp)
clock := newDiscoverytestClock()
h.start(clock)
timer := h.nextOrEnd(t, clock, "the wait after an unusable document")
if got := h.verifierPublished(); got != row.verifier {
t.Errorf("verifier published = %v, want %v", got, row.verifier)
}
if h.published() {
t.Error("the sign-in value was published from an unusable document")
}
if timer == nil {
t.Fatal("Discover returned after an unusable document")
}
if rec := h.login(); rec.Code != http.StatusServiceUnavailable {
t.Errorf("/login with an unusable document: status = %d, want 503, not a redirect", rec.Code)
}
if timer.d < retryloop.DefaultCap || timer.d > retryloop.DefaultCap*3/2 {
t.Errorf("wait after an unusable document = %v, want the cap plus up to half of it", timer.d)
}
lines := h.logs.withMessage(copy10)
if len(lines) != 1 {
t.Fatalf("copy 10 was logged %d times, want once; copy 9 was logged %d times", len(lines), len(h.logs.withMessage(copy9)))
}
if lines[0].level != slog.LevelError {
t.Errorf("copy 10 was logged at %v, want Error from the first line", lines[0].level)
}
if got := lines[0].attrs["issuer"].String(); got != h.idp.url() {
t.Errorf("copy 10 issuer = %q, want %q", got, h.idp.url())
}
if _, ok := lines[0].attrs["retry_in"]; !ok {
t.Error("copy 10 has no retry_in attribute")
}
logged, _ := lines[0].attrs["error"].Any().(error)
row.check(t, logged)
// The provider is fixed; the loop publishes without a new Config.
h.idp.usable()
timer.fire()
discoverytestWaitFor(t, "the sign-in value to be published after the document was fixed", discoverytestWait, h.published)
if !h.verifierPublished() {
t.Error("the verifier is not published although the sign-in value is")
}
done := h.logs.withMessage(copy11)
if len(done) != 1 {
t.Fatalf("copy 11 was logged %d times, want once", len(done))
}
if got := done[0].attrs["issuer"].String(); got != h.idp.url() {
t.Errorf("copy 11 issuer = %q, want %q", got, h.idp.url())
}
if got := done[0].attrs["attempts"].Int64(); got != 2 {
t.Errorf("copy 11 attempts = %d, want 2", got)
}
}
func TestDiscoveryKeepsAnUnusableDocumentOut(t *testing.T) {
rows := []discoverytestUnusableRow{
{
name: "no end_session_endpoint publishes the verifier alone",
configure: (*discoverytestIdP).withoutEndSession,
verifier: true,
check: checkNoEndSessionError,
},
{
name: "an end_session_endpoint that does not parse publishes the verifier alone",
configure: func(i *discoverytestIdP) { i.endSessionAt("%zz") },
verifier: true,
check: checkNoEndSessionError,
},
{
name: "an end_session_endpoint that is not absolute publishes the verifier alone",
configure: func(i *discoverytestIdP) { i.endSessionAt("/logout") },
verifier: true,
check: checkNoEndSessionError,
},
{
name: "another issuer, with an end_session_endpoint, publishes nothing",
configure: (*discoverytestIdP).anotherIssuer,
verifier: false,
check: checkIssuerMismatchError,
},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { runUnusableRow(t, row) })
}
}
// discoverytestAttemptRow is one fake that never finishes an answer.
type discoverytestAttemptRow struct {
name string
run func(*testing.T, *discoverytestHarness)
}
// documentNeverAnswers: the attempt's line is logged within a second.
func documentNeverAnswers(t *testing.T, h *discoverytestHarness) {
h.idp.hangDocuments()
h.start(nil)
discoverytestWaitFor(t, "the attempt's log line within a second of a document request that never answers", time.Second, func() bool {
return len(h.logs.withMessage(copy9)) > 0
})
}
// keySetNeverAnswers: a Verify with no deadline of its own returns within a
// second, so the dedicated client bounds the key set fetch.
func keySetNeverAnswers(t *testing.T, h *discoverytestHarness) {
h.idp.usable()
h.idp.hangKeySet()
h.start(nil)
discoverytestWaitFor(t, "the usable document to be published", discoverytestWait, h.published)
token := h.idp.idTokenFor(t)
verified := make(chan error, 1)
go func() {
_, err := h.cfg.verifier().verifier.Verify(context.Background(), token)
verified <- err
}()
select {
case err := <-verified:
if err == nil {
t.Error("Verify succeeded against a key set that never answered")
}
case <-time.After(time.Second):
t.Fatal("Verify outlasted the attempt timeout")
}
}
func TestDiscoveryAttemptEnds(t *testing.T) {
prev := discoveryAttemptTimeout
discoveryAttemptTimeout = 200 * time.Millisecond
t.Cleanup(func() { discoveryAttemptTimeout = prev })
rows := []discoverytestAttemptRow{
{"a document request that never answers", documentNeverAnswers},
{"a jwks_uri that never answers", keySetNeverAnswers},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { row.run(t, newDiscoverytestHarness(t)) })
}
}
func TestSignInRequestWakesDiscovery(t *testing.T) {
h := newDiscoverytestHarness(t)
clock := newDiscoverytestClock()
h.start(clock)
// Fail until the loop is in a fifteen-second backoff.
var wait *discoverytestTimer
for range 12 {
wait = clock.next(t, "the next backoff")
if wait.d >= retryloop.DefaultCap {
break
}
wait.fire()
}
if wait.d < retryloop.DefaultCap {
t.Fatalf("the backoff never reached the cap; last wait %v", wait.d)
}
waiting := clock.Now()
h.idp.usable()
if rec := h.login(); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("/login during the backoff: status = %d, want 503", rec.Code)
}
short := clock.next(t, "the shortened wait that a /login causes")
if short.d > time.Second {
t.Errorf("a /login left a wait of %v, want at most the one-second gap", short.d)
}
short.fire()
discoverytestWaitFor(t, "the document to be published by the attempt a /login woke", discoverytestWait, h.published)
if advanced := clock.Now().Sub(waiting); advanced > time.Second {
t.Errorf("the clock advanced %v during the fifteen-second backoff, want at most one second", advanced)
}
}
// discoverytestSignOutRow is one state POST /logout meets with no end-session
// endpoint published.
type discoverytestSignOutRow struct {
name string
verifier bool // the verifier is published
htmx bool
wantRevoc bool
}
func runSignOutRow(t *testing.T, row discoverytestSignOutRow) {
h := newDiscoverytestHarness(t)
if row.verifier {
h.idp.withoutEndSession()
h.start(newDiscoverytestClock())
discoverytestWaitFor(t, "the verifier to be published", discoverytestWait, h.verifierPublished)
}
token := sessionAged(t, h.cfg, time.Minute, "refresh-1", "operator-member")
before := h.idp.requests.Load()
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: h.cfg.SessionManager.Cookie.Name, Value: token})
if row.htmx {
req.Header.Set("HX-Request", "true")
}
rec := h.do(http.HandlerFunc(h.cfg.LogoutHandler), req)
if row.htmx {
if rec.Code != http.StatusOK || rec.Header().Get("HX-Redirect") != "/" {
t.Errorf("htmx sign-out: %d with HX-Redirect %q, want 200 with \"/\"", rec.Code, rec.Header().Get("HX-Redirect"))
}
} else if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Errorf("sign-out: %d to %q, want 303 to \"/\"", rec.Code, rec.Header().Get("Location"))
}
if _, found, _ := h.store.Find(token); found {
t.Error("the session is still in the store after sign-out")
}
lines := h.logs.withMessage(copy20)
if len(lines) != 1 || lines[0].level != slog.LevelInfo {
t.Errorf("copy 20 logged %d times, want once at Info", len(lines))
}
revoked := h.idp.revokedTokens()
if row.wantRevoc {
if !slices.Equal(revoked, []string{"refresh-1"}) {
t.Errorf("revocation requests carried %v, want the session's refresh token", revoked)
}
return
}
if got := h.idp.requests.Load() - before; got != 0 {
t.Errorf("the identity provider received %d requests during sign-out, want none", got)
}
}
func TestSignOutWithoutAnEndSessionEndpoint(t *testing.T) {
rows := []discoverytestSignOutRow{
{name: "before publication", verifier: false, htmx: false},
{name: "before publication, htmx", verifier: false, htmx: true},
{name: "verifier published, no end-session endpoint", verifier: true, htmx: false, wantRevoc: true},
{name: "verifier published, no end-session endpoint, htmx", verifier: true, htmx: true, wantRevoc: true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { runSignOutRow(t, row) })
}
}
func TestSignedInSessionPassesBeforeDiscovery(t *testing.T) {
h := newDiscoverytestHarness(t)
token := sessionAged(t, h.cfg, 6*time.Minute, "refresh-1", "operator-member")
before := h.idp.requests.Load()
reached, roles, _ := through(t, h.cfg, token)
if !reached {
t.Fatal("the request did not reach the handler before discovery")
}
if !slices.Equal(roles, []string{"operator-member"}) {
t.Errorf("roles = %v, want the session's own", roles)
}
if got := h.idp.requests.Load() - before; got != 0 {
t.Errorf("the identity provider received %d requests, want none", got)
}
}
func TestRolesAreRederivedWhileTheDocumentIsUnusableForSignIn(t *testing.T) {
h := newDiscoverytestHarness(t)
h.idp.withoutEndSession()
h.idp.rolesAtTokenEndpoint(t)
h.start(newDiscoverytestClock())
discoverytestWaitFor(t, "the verifier to be published", discoverytestWait, h.verifierPublished)
token := sessionAged(t, h.cfg, 6*time.Minute, "refresh-1", "operator-member")
reached, roles, _ := through(t, h.cfg, token)
if !reached {
t.Fatal("the request did not reach the handler")
}
if len(roles) != 0 {
t.Errorf("the role removed at the provider is still carried: roles = %v", roles)
}
if rec := h.login(); rec.Code != http.StatusServiceUnavailable {
t.Errorf("/login while the document lacks an end-session endpoint: status = %d, want 503", rec.Code)
}
}
// discoverytestFailures collects what the load test's goroutines saw wrong.
type discoverytestFailures struct {
mu sync.Mutex
list []string
}
func (f *discoverytestFailures) add(msg string) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.list) < 10 {
f.list = append(f.list, msg)
}
}
func TestDiscoveryPublishesUnderLoad(t *testing.T) {
h := newDiscoverytestHarness(t)
h.idp.rolesAtTokenEndpoint(t)
h.start(nil)
token := sessionAged(t, h.cfg, 6*time.Minute, "refresh-1")
var failures discoverytestFailures
var served atomic.Int64
stop := make(chan struct{})
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
loginRec := httptest.NewRecorder()
h.cfg.SessionManager.LoadAndSave(http.HandlerFunc(h.cfg.LoginHandler)).
ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/login", nil))
if loginRec.Code != http.StatusServiceUnavailable && loginRec.Code != http.StatusFound {
failures.add("/login answered " + http.StatusText(loginRec.Code))
}
req := httptest.NewRequest(http.MethodGet, "/operator", nil)
req.AddCookie(&http.Cookie{Name: h.cfg.SessionManager.Cookie.Name, Value: token})
pageRec := httptest.NewRecorder()
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
h.cfg.SessionManager.LoadAndSave(h.cfg.Middleware()(next)).ServeHTTP(pageRec, req)
if pageRec.Code != http.StatusOK {
failures.add("the signed-in page answered " + http.StatusText(pageRec.Code))
}
served.Add(1)
}
}()
}
discoverytestWaitFor(t, "requests to be served before the switch", discoverytestWait, func() bool { return served.Load() >= 20 })
h.idp.usable()
discoverytestWaitFor(t, "the document to be published under load", discoverytestWait, h.published)
after := served.Load()
discoverytestWaitFor(t, "requests to be served after the publication", discoverytestWait, func() bool { return served.Load() >= after+100 })
close(stop)
wg.Wait()
for _, msg := range failures.list {
t.Error(msg)
}
}
+32 -18
View File
@@ -6,6 +6,7 @@ package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -449,7 +450,7 @@ func TestLogoutCallbackForgedRequestsLeaveALiveSessionAlone(t *testing.T) {
// fakeDiscovery serves an OpenID discovery document whose issuer is its own
// URL, with the endpoints given, so a real *oidc.Provider can be built from
// it without a network.
func fakeDiscovery(t *testing.T, extra map[string]any) *oidc.Provider {
func fakeDiscovery(t *testing.T, extra map[string]any) (*oidc.Provider, string) {
t.Helper()
var issuer string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -475,21 +476,22 @@ func fakeDiscovery(t *testing.T, extra map[string]any) *oidc.Provider {
if err != nil {
t.Fatalf("discovery: %v", err)
}
return provider
return provider, issuer
}
// The two endpoints sign-out needs come from discovery, once, at Setup. The
// end-session endpoint is required (OpenID Connect RP-Initiated Logout 1.0,
// section 2.1): a provider without one cannot end its session for the person,
// and there is no path worth guessing. The revocation endpoint (RFC 7009) is
// optional.
// The two endpoints sign-out needs come from the discovery document, which
// providerEndpoints reads after checking its issuer against the configured
// one. The end-session endpoint is required (OpenID Connect RP-Initiated
// Logout 1.0, section 2.1): a provider without one cannot end its session for
// the person, and there is no path worth guessing. The revocation endpoint
// (RFC 7009) is optional.
func TestProviderEndpointsComeFromDiscovery(t *testing.T) {
t.Run("both published", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{
provider, issuer := fakeDiscovery(t, map[string]any{
"end_session_endpoint": "https://idp.example/session/end",
"revocation_endpoint": "https://idp.example/revoke",
})
endSession, revocation, err := providerEndpoints(provider)
endSession, revocation, err := providerEndpoints(provider, issuer)
if err != nil {
t.Fatalf("providerEndpoints: %v", err)
}
@@ -498,23 +500,35 @@ func TestProviderEndpointsComeFromDiscovery(t *testing.T) {
}
})
t.Run("no revocation endpoint is allowed", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{"end_session_endpoint": "https://idp.example/session/end"})
_, revocation, err := providerEndpoints(provider)
provider, issuer := fakeDiscovery(t, map[string]any{"end_session_endpoint": "https://idp.example/session/end"})
_, revocation, err := providerEndpoints(provider, issuer)
if err != nil || revocation != "" {
t.Errorf("got revocation %q, err %v", revocation, err)
}
})
t.Run("no end-session endpoint refuses to start", func(t *testing.T) {
provider := fakeDiscovery(t, nil)
if _, _, err := providerEndpoints(provider); err == nil || !strings.Contains(err.Error(), "end_session_endpoint") {
t.Errorf("want an error naming end_session_endpoint, got %v", err)
t.Run("no end-session endpoint is its own error and still returns the revocation endpoint", func(t *testing.T) {
provider, issuer := fakeDiscovery(t, map[string]any{"revocation_endpoint": "https://idp.example/revoke"})
_, revocation, err := providerEndpoints(provider, issuer)
if !errors.Is(err, errNoEndSession) || !strings.Contains(err.Error(), "end_session_endpoint") {
t.Errorf("want the missing-endpoint error naming end_session_endpoint, got %v", err)
}
if revocation != "https://idp.example/revoke" {
t.Errorf("revocation = %q, want the document's, which the verifier needs", revocation)
}
})
t.Run("another issuer is a typed error", func(t *testing.T) {
provider, _ := fakeDiscovery(t, map[string]any{"end_session_endpoint": "https://idp.example/session/end"})
_, _, err := providerEndpoints(provider, "https://configured.example/realms/main")
var mismatch *issuerMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("want an *issuerMismatchError, got %v", err)
}
})
}
// The handler sends the browser to the endpoint Setup discovered and nothing
// else; a Config without one is a construction error, refused rather than
// guessed around.
// The handler sends the browser to the end-session endpoint the Config
// carries and nothing else; a Config without one is a construction error,
// refused rather than guessed around.
func TestLogoutGoesToTheDiscoveredEndSessionEndpoint(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
+1 -1
View File
@@ -74,7 +74,7 @@ document.body.addEventListener('htmx:response:error', function(evt) {
// 5xx server errors (in noSwap: the swap is suppressed).
if (status >= 500) {
showErrorToast('Server error. Please try again later.');
showErrorToast('Server error. Try again later.');
signalReorderRecover();
return;
}
+342
View File
@@ -0,0 +1,342 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Package retryloop runs one attempt function until it succeeds or its
// context ends, with a capped, jittered backoff and a log that decays.
//
// It serves the two dependencies the console waits for in the background, the
// identity provider's discovery document and the Temporal connection, so that
// both follow one policy and the bound on log lines is proved once.
//
// The waits. The first wait is FirstWait (0.5 seconds) and doubles after each
// failed attempt up to Cap (15 seconds). Each wait is the base wait plus a
// uniform random fraction of up to half of it, so a wait at the cap is between
// 15 and 22.5 seconds. A failure marked with AtCap makes the base of the next
// wait the cap, whatever the attempt count; the jitter still applies.
//
// The wake. A send on Policy.Wake ends the wait, but not sooner than MinWakeGap
// (1 second) after the last attempt began, so a burst of wakes makes at most one
// attempt a second. A wake does not end a wait that follows an AtCap failure.
//
// The log. Every line goes through Run; an attempt logs nothing itself. A
// failed attempt is written when it is the first failure, when it is the first
// line written after Policy.Escalation has elapsed, when its error text differs
// from the last line's and 30 seconds have passed since that line, and
// otherwise once 5 minutes have passed since the last line. A line is Warn until
// Policy.Escalation has elapsed since Run began and Error after, and a
// Failure's Level is a floor under that. The success is one Info line.
// Every failure line carries the attributes attempts (the attempts made so
// far), elapsed, retry_in and error, after Policy.Attrs; the success line
// carries attempts and elapsed.
//
// The bound. With a steady error, an hour of retries writes at most 14 lines:
// the first, the first Error line, and one per 5 minutes. With an error whose
// text changes on every attempt it writes at most 120 more, one per 30 seconds.
package retryloop
import (
"context"
"errors"
"log/slog"
"math/rand/v2"
"time"
)
// The defaults a zero Policy field takes.
const (
// DefaultFirstWait is the wait after the first failed attempt.
DefaultFirstWait = 500 * time.Millisecond
// DefaultCap is the longest base wait.
DefaultCap = 15 * time.Second
// DefaultMinWakeGap is how long after an attempt began a wake counts.
DefaultMinWakeGap = time.Second
// DefaultEscalation is the time after which the lines are Error.
DefaultEscalation = 90 * time.Second
// changedTextGap is the least time between two lines whose error text
// differs, and repeatGap the least time between two lines that repeat.
changedTextGap = 30 * time.Second
repeatGap = 5 * time.Minute
)
// Clock is the time the loop reads and waits on.
type Clock interface {
Now() time.Time
NewTimer(d time.Duration) Timer
}
// Timer is a one-shot timer, as *time.Timer is.
type Timer interface {
C() <-chan time.Time
Stop() bool
}
// Source is the random source of the jitter: Float64 returns a number in
// [0, 1), as *math/rand/v2.Rand does.
type Source interface {
Float64() float64
}
// Policy is how Run waits and what it logs. Its zero value is usable: the
// zero FirstWait, Cap, MinWakeGap and Escalation take the defaults above, the
// Logger is slog's default, and the clock and the random source are the real
// ones.
type Policy struct {
// FirstWait is the wait after the first failure, doubled after each
// failed attempt until Cap.
FirstWait time.Duration
// Cap is the longest base wait, and the base of the wait after an AtCap failure.
Cap time.Duration
// Escalation is the time after Run began from which the lines are Error.
Escalation time.Duration
// Wake, when set, ends a wait that follows an ordinary failure.
Wake <-chan struct{}
// MinWakeGap is the least time between the start of an attempt and the
// start of the next one that a wake causes.
MinWakeGap time.Duration
// FailureMessage is the message of a failed attempt that returns a plain
// error, or a Failure with no Message.
FailureMessage string
// SuccessMessage is the message of the Info line written on success.
SuccessMessage string
// Attrs are carried by every line, before the loop's own.
Attrs []slog.Attr
// Logger writes the lines. Nil means slog.Default().
Logger *slog.Logger
// Clock is the time source. Nil means the real clock.
Clock Clock
// Rand is the jitter's source. Nil means the package-level source of
// math/rand/v2.
Rand Source
}
// Failure is a failed attempt that carries its own log line: the message to
// write in place of the policy's, a level floor, and the error that failed.
// Return it by value or by pointer. A Level at or below Warn changes nothing; Error writes the line at Error
// from the first.
type Failure struct {
Message string
Level slog.Level
Err error
}
// Error returns the text of the wrapped error.
func (f Failure) Error() string {
if f.Err == nil {
return f.Message
}
return f.Err.Error()
}
// Unwrap returns the wrapped error.
func (f Failure) Unwrap() error { return f.Err }
// atCap marks an error after which the base of the next wait is the cap.
type atCap struct{ err error }
func (a atCap) Error() string { return a.err.Error() }
func (a atCap) Unwrap() error { return a.err }
// AtCap marks err so that the base of the wait after it is the cap and a wake
// does not end that wait. A nil err stays nil.
func AtCap(err error) error {
if err == nil {
return nil
}
return atCap{err}
}
// Run calls attempt until it returns nil, and then returns nil, or until ctx
// ends, and then returns ctx.Err(); a ctx that has already ended returns before
// the first attempt. An error from attempt is retried after the
// wait the policy gives, and logged as the package comment says.
func Run(ctx context.Context, policy Policy, attempt func(context.Context) error) error {
if err := ctx.Err(); err != nil {
return err
}
l := newLoop(policy)
l.start = l.clock.Now()
for {
began := l.clock.Now()
l.attempts++
err := attempt(ctx)
if err == nil {
l.logSuccess()
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
wait, wakeable := l.nextWait(err)
l.logFailure(err, wait)
if err := l.sleep(ctx, wait, began, wakeable); err != nil {
return err
}
}
}
// loop is the state of one Run.
type loop struct {
p Policy
clock Clock
rand Source
logger *slog.Logger
start time.Time
attempts int
base time.Duration // the base wait after the next failure
logged bool
escalationLogged bool // a line has been written since Escalation elapsed
lastAt time.Time
lastText string
}
func newLoop(p Policy) *loop {
if p.FirstWait <= 0 {
p.FirstWait = DefaultFirstWait
}
if p.Cap <= 0 {
p.Cap = DefaultCap
}
if p.MinWakeGap <= 0 {
p.MinWakeGap = DefaultMinWakeGap
}
if p.Escalation <= 0 {
p.Escalation = DefaultEscalation
}
l := &loop{p: p, clock: p.Clock, rand: p.Rand, logger: p.Logger, base: p.FirstWait}
if l.clock == nil {
l.clock = realClock{}
}
if l.rand == nil {
l.rand = globalSource{}
}
if l.logger == nil {
l.logger = slog.Default()
}
return l
}
// nextWait returns the wait after the failed attempt err, jitter included,
// and whether a wake may end it.
func (l *loop) nextWait(err error) (time.Duration, bool) {
base := l.base
l.base = min(l.base*2, l.p.Cap)
var pinned atCap
atTheCap := errors.As(err, &pinned)
if atTheCap {
base = l.p.Cap
}
jitter := time.Duration(l.rand.Float64() * float64(base) / 2)
return base + jitter, !atTheCap
}
// sleep waits d, or less when a wake counts, and returns ctx.Err() when ctx
// ends first.
func (l *loop) sleep(ctx context.Context, d time.Duration, began time.Time, wakeable bool) error {
timer := l.clock.NewTimer(d)
defer func() { timer.Stop() }()
ends := l.clock.Now().Add(d)
var wake <-chan struct{}
if wakeable {
wake = l.p.Wake
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C():
return nil
case <-wake:
wake = nil
earliest := began.Add(l.p.MinWakeGap)
remaining := earliest.Sub(l.clock.Now())
if remaining <= 0 {
return nil
}
if earliest.Before(ends) {
timer.Stop()
timer = l.clock.NewTimer(remaining)
ends = earliest
}
}
}
}
// attrs are the attributes every line carries.
func (l *loop) attrs(retryIn time.Duration, err error) []slog.Attr {
attrs := append([]slog.Attr(nil), l.p.Attrs...)
attrs = append(attrs,
slog.Int("attempts", l.attempts),
slog.Duration("elapsed", l.clock.Now().Sub(l.start)))
if err != nil {
attrs = append(attrs,
slog.Duration("retry_in", retryIn),
slog.Any("error", err))
}
return attrs
}
func (l *loop) logSuccess() {
l.logger.LogAttrs(context.Background(), slog.LevelInfo, l.p.SuccessMessage, l.attrs(0, nil)...)
}
// logFailure writes the line for a failed attempt when the decay allows it.
func (l *loop) logFailure(err error, retryIn time.Duration) {
now := l.clock.Now()
message, level := l.p.FailureMessage, slog.LevelWarn
escalated := now.Sub(l.start) >= l.p.Escalation
if escalated {
level = slog.LevelError
}
shown := err
var failure Failure
var byPointer *Failure
found := errors.As(err, &failure)
if !found && errors.As(err, &byPointer) && byPointer != nil {
failure, found = *byPointer, true
}
if found {
if failure.Message != "" {
message = failure.Message
}
level = max(level, failure.Level)
if failure.Err != nil {
shown = failure.Err
}
}
if !l.due(now, shown.Error()) {
return
}
l.logged, l.lastAt, l.lastText = true, now, shown.Error()
l.escalationLogged = l.escalationLogged || escalated
l.logger.LogAttrs(context.Background(), level, message, l.attrs(retryIn, shown)...)
}
// due reports whether a failure with this error text is written now.
func (l *loop) due(now time.Time, text string) bool {
if !l.logged || (!l.escalationLogged && now.Sub(l.start) >= l.p.Escalation) {
return true
}
since := now.Sub(l.lastAt)
if text != l.lastText {
return since >= changedTextGap
}
return since >= repeatGap
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func (realClock) NewTimer(d time.Duration) Timer { return realTimer{time.NewTimer(d)} }
type realTimer struct{ t *time.Timer }
func (r realTimer) C() <-chan time.Time { return r.t.C }
func (r realTimer) Stop() bool { return r.t.Stop() }
type globalSource struct{}
func (globalSource) Float64() float64 { return rand.Float64() }
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package retryloop
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"sync"
"testing"
"testing/synctest"
"time"
)
// deadline bounds every wait a test makes on the loop's goroutine; a test
// that reaches it fails naming what it waited for.
const deadline = 5 * time.Second
// fakeClock is the loop's clock. In auto mode a timer fires at once and moves
// the clock by its duration, so a run is a sequence of recorded waits. In
// manual mode a timer is announced on timers and fires when the test says so.
type fakeClock struct {
mu sync.Mutex
now time.Time
auto bool
waits []time.Duration
timers chan *fakeTimer
}
type fakeTimer struct {
clock *fakeClock
c chan time.Time
d time.Duration
}
func newClock(auto bool) *fakeClock {
return &fakeClock{
now: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC),
auto: auto,
timers: make(chan *fakeTimer, 64),
}
}
func (f *fakeClock) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *fakeClock) advance(d time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.now = f.now.Add(d)
}
func (f *fakeClock) NewTimer(d time.Duration) Timer {
t := &fakeTimer{clock: f, c: make(chan time.Time, 1), d: d}
f.mu.Lock()
f.waits = append(f.waits, d)
auto := f.auto
f.mu.Unlock()
if auto {
t.fire()
return t
}
f.timers <- t
return t
}
func (t *fakeTimer) fire() {
t.clock.advance(t.d)
t.c <- t.clock.Now()
}
func (t *fakeTimer) C() <-chan time.Time { return t.c }
func (t *fakeTimer) Stop() bool { return true }
// seeded returns two sources that yield the same numbers, so a test can
// compute the jitter the loop drew.
func seeded() (*rand.Rand, *rand.Rand) {
return rand.New(rand.NewPCG(7, 11)), rand.New(rand.NewPCG(7, 11))
}
// noJitter draws zero every time, so a wait is exactly its base.
type noJitter struct{}
func (noJitter) Float64() float64 { return 0 }
// recordingHandler keeps what the loop logged.
type recordingHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r)
return nil
}
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
func (h *recordingHandler) lines() []logLine {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]logLine, 0, len(h.records))
for _, r := range h.records {
line := logLine{level: r.Level, message: r.Message, attrs: map[string]slog.Value{}}
r.Attrs(func(a slog.Attr) bool {
line.attrs[a.Key] = a.Value
return true
})
out = append(out, line)
}
return out
}
type logLine struct {
level slog.Level
message string
attrs map[string]slog.Value
}
func (l logLine) elapsed() time.Duration { return l.attrs["elapsed"].Duration() }
// started is a Run in its own goroutine.
type started struct {
result chan error
}
func start(ctx context.Context, p Policy, attempt func(context.Context) error) *started {
s := &started{result: make(chan error, 1)}
go func() { s.result <- Run(ctx, p, attempt) }()
return s
}
func (s *started) await(t *testing.T, what string) error {
t.Helper()
select {
case err := <-s.result:
return err
case <-time.After(deadline):
t.Fatalf("waited %v for %s", deadline, what)
return nil
}
}
// nextTimer waits for the loop to announce its next wait.
func nextTimer(t *testing.T, c *fakeClock, what string) *fakeTimer {
t.Helper()
select {
case timer := <-c.timers:
return timer
case <-time.After(deadline):
t.Fatalf("waited %v for %s", deadline, what)
return nil
}
}
// failing returns an attempt that fails with err each time and signals the
// clock reading at each start.
func failing(c *fakeClock, err error, began chan<- time.Time) func(context.Context) error {
return func(context.Context) error {
began <- c.Now()
return err
}
}
func receive[T any](t *testing.T, ch <-chan T, what string) T {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(deadline):
t.Fatalf("waited %v for %s", deadline, what)
var zero T
return zero
}
}
func TestRunWaitsFollowThePolicy(t *testing.T) {
clock := newClock(true)
drawn, expected := seeded()
attempts := 0
p := Policy{Clock: clock, Rand: drawn, Logger: slog.New(&recordingHandler{})}
err := Run(t.Context(), p, func(context.Context) error {
attempts++
if attempts <= 8 {
return errors.New("refused")
}
return nil
})
if err != nil {
t.Fatalf("Run = %v, want nil", err)
}
bases := []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 15 * time.Second, 15 * time.Second, 15 * time.Second}
if len(clock.waits) != len(bases) {
t.Fatalf("waits = %v, want %d of them", clock.waits, len(bases))
}
for i, base := range bases {
want := base + time.Duration(expected.Float64()*float64(base)/2)
if clock.waits[i] != want {
t.Errorf("wait %d = %v, want %v (base %v plus the seeded fraction of half of it)", i+1, clock.waits[i], want, base)
}
}
}
// wakeRow is one attempt that takes took, then a wake in the wait after it.
type wakeRow struct {
name string
took time.Duration
// gap is the time from the start of the first attempt to the start of the
// second.
gap time.Duration
}
func runWakeRow(t *testing.T, row wakeRow) {
clock := newClock(false)
wake := make(chan struct{}, 1)
began := make(chan time.Time, 4)
calls := 0
p := Policy{FirstWait: 15 * time.Second, Cap: 15 * time.Second, Clock: clock, Rand: noJitter{}, Wake: wake, Logger: slog.New(&recordingHandler{})}
run := start(t.Context(), p, func(context.Context) error {
began <- clock.Now()
calls++
if calls == 1 {
clock.advance(row.took)
return errors.New("refused")
}
return nil
})
first := receive(t, began, "the first attempt")
wait := nextTimer(t, clock, "the fifteen-second wait")
if wait.d != 15*time.Second {
t.Fatalf("first wait = %v, want 15s", wait.d)
}
wake <- struct{}{}
if remaining := row.gap - row.took; remaining > 0 {
short := nextTimer(t, clock, "the wait that a wake shortens to the one-second gap")
if short.d != remaining {
t.Fatalf("a wake %v into the attempt left a wait of %v, want %v", row.took, short.d, remaining)
}
short.fire()
}
second := receive(t, began, "the attempt a wake starts")
if got := second.Sub(first); got != row.gap {
t.Errorf("the second attempt began %v after the first, want %v", got, row.gap)
}
if err := run.await(t, "Run to end after the second attempt"); err != nil {
t.Errorf("Run = %v, want nil", err)
}
}
func TestRunWakeEndsTheWait(t *testing.T) {
rows := []wakeRow{
{name: "a wake right after the attempt waits for the second", took: 0, gap: time.Second},
{name: "a wake within the second waits for the rest of it", took: 300 * time.Millisecond, gap: time.Second},
{name: "a wake after the second starts the attempt at once", took: 4 * time.Second, gap: 4 * time.Second},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { runWakeRow(t, row) })
}
}
func TestRunAtCapWaitsTheCap(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
clock := newClock(false)
// The wake channel is buffered as the discovery loop's is. A loop that
// listened for a wake during an AtCap wait would take it as soon as it is
// sent and start a one-second timer; this one leaves it unread.
wake := make(chan struct{}, 1)
began := make(chan time.Time, 4)
calls := 0
drawn, expected := seeded()
p := Policy{Clock: clock, Rand: drawn, Wake: wake, Logger: slog.New(&recordingHandler{})}
run := start(t.Context(), p, func(context.Context) error {
began <- clock.Now()
calls++
if calls == 1 {
return AtCap(errors.New("unusable"))
}
return nil
})
first := receive(t, began, "the first attempt")
wait := nextTimer(t, clock, "the wait after an AtCap failure")
wantWait := DefaultCap + time.Duration(expected.Float64()*float64(DefaultCap)/2)
if wait.d != wantWait {
t.Fatalf("wait after an AtCap failure = %v, want the cap plus the seeded jitter, %v", wait.d, wantWait)
}
synctest.Wait() // the loop is blocked in the wait after the AtCap failure
wake <- struct{}{}
synctest.Wait() // a loop that listens for the wake has taken it by now
if len(wake) != 1 || len(clock.timers) != 0 {
t.Fatalf("a wake during an AtCap wait was read")
}
// The next attempt starts only when the fake clock reaches the cap plus the
// jitter, which firing the timer does.
wait.fire()
second := receive(t, began, "the second attempt")
if got := second.Sub(first); got != wantWait {
t.Errorf("the second attempt began %v after the first, want the cap plus the seeded jitter, %v", got, wantWait)
}
if err := run.await(t, "Run to end after the second attempt"); err != nil {
t.Errorf("Run = %v, want nil", err)
}
if len(clock.waits) != 1 {
t.Errorf("waits = %v, want only the cap's wait; a wake must not start a shorter one", clock.waits)
}
})
}
func TestRunAtCapPinsOnlyItsOwnWait(t *testing.T) {
clock := newClock(true)
calls := 0
p := Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(&recordingHandler{})}
err := Run(t.Context(), p, func(context.Context) error {
calls++
switch calls {
case 1:
return AtCap(errors.New("unusable"))
case 2:
return errors.New("refused")
}
return nil
})
if err != nil {
t.Fatalf("Run = %v, want nil", err)
}
want := []time.Duration{DefaultCap, 2 * DefaultFirstWait}
if len(clock.waits) != len(want) || clock.waits[0] != want[0] || clock.waits[1] != want[1] {
t.Errorf("waits = %v, want %v: the cap after the AtCap failure, then the ordinary sequence", clock.waits, want)
}
}
func TestRunEndedContextMakesNoAttempt(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
calls := 0
err := Run(ctx, Policy{Clock: newClock(true), Logger: slog.New(&recordingHandler{})}, func(context.Context) error {
calls++
return nil
})
if !errors.Is(err, context.Canceled) {
t.Errorf("Run = %v, want context.Canceled", err)
}
if calls != 0 {
t.Errorf("a context that had ended made %d attempts, want none", calls)
}
}
func runFailureForm(t *testing.T, failure error) {
clock := newClock(true)
handler := &recordingHandler{}
p := Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(handler), FailureMessage: "policy message"}
calls := 0
err := Run(t.Context(), p, func(context.Context) error {
calls++
if calls == 1 {
return failure
}
return nil
})
if err != nil {
t.Fatalf("Run = %v, want nil", err)
}
lines := handler.lines()
if len(lines) == 0 {
t.Fatal("nothing was logged")
}
if lines[0].level != slog.LevelError || lines[0].message != "unusable" {
t.Errorf("failure line is %v %q, want Error \"unusable\"", lines[0].level, lines[0].message)
}
}
func TestRunFailureForms(t *testing.T) {
rows := []struct {
name string
failure error
}{
{"by value", Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad")}},
{"by pointer", &Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad")}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { runFailureForm(t, row.failure) })
}
}
func TestRunStopsWhenTheContextEnds(t *testing.T) {
clock := newClock(false)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
began := make(chan time.Time, 4)
run := start(ctx, Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(&recordingHandler{})},
failing(clock, errors.New("refused"), began))
receive(t, began, "the first attempt")
nextTimer(t, clock, "the wait after the first attempt")
cancel()
if err := run.await(t, "Run to return after the context ended"); !errors.Is(err, context.Canceled) {
t.Errorf("Run = %v, want context.Canceled", err)
}
select {
case <-began:
t.Error("an attempt began after the context ended")
default:
}
}
// hour is how long a decay row runs on the fake clock.
const hour = time.Hour
// runForAnHour calls attempt until an hour has passed on the fake clock and
// returns what was logged.
func runForAnHour(t *testing.T, p Policy, attempt func(n int) error) []logLine {
t.Helper()
clock := newClock(true)
handler := &recordingHandler{}
p.Clock, p.Logger = clock, slog.New(handler)
drawn, _ := seeded()
p.Rand = drawn
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
began := clock.Now()
n := 0
err := Run(ctx, p, func(context.Context) error {
n++
if clock.Now().Sub(began) >= hour {
cancel()
}
return attempt(n)
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run = %v, want context.Canceled at the end of the hour", err)
}
return handler.lines()
}
func checkLinesCarry(t *testing.T, lines []logLine) {
t.Helper()
for i, line := range lines {
for _, key := range []string{"attempts", "elapsed", "retry_in", "error"} {
if _, ok := line.attrs[key]; !ok {
t.Errorf("line %d (%q) has no %s attribute", i+1, line.message, key)
}
}
}
}
func decaySteadyError(t *testing.T) {
lines := runForAnHour(t, Policy{FailureMessage: "refused"}, func(int) error { return errors.New("connection refused") })
checkLinesCarry(t, lines)
if len(lines) > 14 {
t.Errorf("an hour of one error wrote %d lines, want at most 14", len(lines))
}
if len(lines) == 0 || lines[0].level != slog.LevelWarn {
t.Fatalf("the first line must be Warn, got %v", lines)
}
firstError := -1
for i, line := range lines {
wantLevel := slog.LevelWarn
if line.elapsed() >= DefaultEscalation {
wantLevel = slog.LevelError
}
if line.level != wantLevel {
t.Errorf("line %d at %v is %v, want %v", i+1, line.elapsed(), line.level, wantLevel)
}
if line.level == slog.LevelError && firstError < 0 {
firstError = i
}
}
if firstError < 0 {
t.Fatal("no line was written at Error after the escalation time")
}
if got := lines[firstError].elapsed(); got >= DefaultEscalation+DefaultCap*2 {
t.Errorf("the first Error line was written at %v, want when %v passed", got, DefaultEscalation)
}
}
func decayChangingError(t *testing.T) {
lines := runForAnHour(t, Policy{FailureMessage: "refused", Escalation: 24 * hour}, func(n int) error {
return fmt.Errorf("refused on attempt %d", n)
})
checkLinesCarry(t, lines)
if len(lines) > 121 {
t.Errorf("an hour of changing errors wrote %d lines, want at most 121", len(lines))
}
for i := 1; i < len(lines); i++ {
if gap := lines[i].elapsed() - lines[i-1].elapsed(); gap < 30*time.Second {
t.Errorf("lines %d and %d are %v apart, want at least 30s", i, i+1, gap)
}
}
}
func decayFailureLevel(t *testing.T) {
lines := runForAnHour(t, Policy{FailureMessage: "unused"}, func(int) error {
return Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad document")}
})
checkLinesCarry(t, lines)
if len(lines) > 14 {
t.Errorf("an hour of an Error failure wrote %d lines, want at most 14", len(lines))
}
for i, line := range lines {
if line.level != slog.LevelError || line.message != "unusable" {
t.Errorf("line %d is %v %q, want Error \"unusable\"", i+1, line.level, line.message)
}
}
if len(lines) == 0 {
t.Fatal("nothing was logged")
}
}
func decayRecovery(t *testing.T) {
clock := newClock(true)
handler := &recordingHandler{}
p := Policy{
Clock: clock, Rand: noJitter{}, Logger: slog.New(handler),
FailureMessage: "refused", SuccessMessage: "connected", Attrs: []slog.Attr{slog.String("host", "h")},
}
n := 0
began := clock.Now()
err := Run(t.Context(), p, func(context.Context) error {
n++
clock.advance(40 * time.Second)
if n <= 3 {
return fmt.Errorf("refused on attempt %d", n)
}
return nil
})
if err != nil {
t.Fatalf("Run = %v, want nil", err)
}
lines := handler.lines()
if len(lines) != 4 {
t.Fatalf("logged %d lines, want three failures and one success", len(lines))
}
checkLinesCarry(t, lines[:3])
for i, line := range lines[:3] {
if got := line.attrs["attempts"].Int64(); got != int64(i+1) {
t.Errorf("failure line %d attempts = %d, want %d", i+1, got, i+1)
}
}
last := lines[3]
if last.level != slog.LevelInfo || last.message != "connected" {
t.Errorf("last line is %v %q, want Info \"connected\"", last.level, last.message)
}
if got := last.attrs["attempts"].Int64(); got != 4 {
t.Errorf("success line attempts = %d, want 4", got)
}
if got, want := last.elapsed(), clock.Now().Sub(began); got != want {
t.Errorf("success line elapsed = %v, want %v", got, want)
}
if got := last.attrs["host"].String(); got != "h" {
t.Errorf("success line lost the policy's attribute: host = %q", got)
}
}
func decayAlternatingLevels(t *testing.T) {
lines := runForAnHour(t, Policy{FailureMessage: "refused", Escalation: 24 * hour}, func(n int) error {
if n%2 == 0 {
return Failure{Level: slog.LevelError, Err: errors.New("unusable")}
}
return errors.New("refused")
})
if len(lines) < 2 {
t.Fatalf("wrote %d lines, want at least 2", len(lines))
}
for i := 1; i < len(lines); i++ {
if gap := lines[i].elapsed() - lines[i-1].elapsed(); gap < 30*time.Second {
t.Errorf("lines %d and %d are %v apart, want at least 30s", i, i+1, gap)
}
}
}
func TestRunLogDecay(t *testing.T) {
rows := []struct {
name string
run func(*testing.T)
}{
{"an hour of one error", decaySteadyError},
{"an hour of an error that changes", decayChangingError},
{"a failure with level Error", decayFailureLevel},
{"levels that alternate before the escalation", decayAlternatingLevels},
{"three failures then a success", decayRecovery},
}
for _, row := range rows {
t.Run(row.name, row.run)
}
}
+55 -8
View File
@@ -146,6 +146,13 @@ func routeAuth() *auth.Config {
// newRouteEnv builds the router from the configured profile, after edit
// changes it. The viper keys the probed handlers read are fixed for the test.
func newRouteEnv(t *testing.T, edit func(*Config)) *routeEnv {
t.Helper()
return newRouteEnvOver(t, edit, routeAuth())
}
// newRouteEnvOver is newRouteEnv over authConfig, which a test that wants the
// console's discovery state gives in place of routeAuth's hand-built one.
func newRouteEnvOver(t *testing.T, edit func(*Config), authConfig *auth.Config) *routeEnv {
t.Helper()
setViper(t, "base-url", routesBaseURL)
setViper(t, "oidc-idp-issuer-url", "https://idp.test/realms/console")
@@ -155,7 +162,6 @@ func newRouteEnv(t *testing.T, edit func(*Config)) *routeEnv {
if edit != nil {
edit(&cfg)
}
authConfig := routeAuth()
rt, err := newRouter(cfg, authConfig)
if err != nil {
t.Fatalf("newRouter: %v", err)
@@ -275,6 +281,9 @@ func answer(t *testing.T, rec *httptest.ResponseRecorder) string {
if hx := rec.Header().Get("HX-Redirect"); hx != "" {
fmt.Fprintf(&b, " hx=%s", hx)
}
if retry := rec.Header().Get("Retry-After"); retry != "" {
fmt.Fprintf(&b, " retry-after=%s", retry)
}
b.WriteString(bodyFacts(t, rec))
b.WriteString(headerFacts(rec.Header()))
return b.String()
@@ -360,11 +369,10 @@ var extraProbes = []extraProbe{
{"a cross-origin read", http.MethodGet, "/static/app.css", func(r *http.Request) { r.Header.Set("Origin", routesElsewhere) }},
}
// TestNewHandlerRouteTable holds every route's pattern and answers to
// testdata/routes.golden, with the extra probes and the one row a
// configuration without a Stripe key answers differently.
func TestNewHandlerRouteTable(t *testing.T) {
env := newRouteEnv(t, nil)
// routeTable is every row's answers, the extra probes' and the bare
// configuration's, as the golden files hold them.
func routeTable(t *testing.T, env *routeEnv) string {
t.Helper()
var b strings.Builder
for _, r := range routeRows(t) {
b.WriteString(env.rowAnswers(t, r))
@@ -374,11 +382,50 @@ func TestNewHandlerRouteTable(t *testing.T) {
_, pattern := env.rt.mux.Handler(req)
fmt.Fprintf(&b, "extra: %s %s %s pattern=%s\n anon: %s\n", x.name, x.method, x.path, pattern, answer(t, env.serve(req)))
}
bare := newRouteEnv(t, func(c *Config) { c.StripeAPIKey = "" })
bare := newRouteEnvOver(t, func(c *Config) { c.StripeAPIKey = "" }, env.auth)
req := probe(http.MethodPost, "/billing/checkout", nil)
_, pattern := bare.rt.mux.Handler(req)
fmt.Fprintf(&b, "bare: POST /billing/checkout pattern=%s\n anon: %s\n", pattern, answer(t, bare.serve(req)))
golden.Compare(t, "testdata/routes.golden", b.String())
return b.String()
}
// TestNewHandlerRouteTable holds every route's pattern and answers to
// testdata/routes.golden, with the extra probes and the one row a
// configuration without a Stripe key answers differently.
func TestNewHandlerRouteTable(t *testing.T) {
golden.Compare(t, "testdata/routes.golden", routeTable(t, newRouteEnv(t, nil)))
}
// TestNewHandlerRouteTableNotReady is the same table over the console's own
// authentication configuration before the identity provider's discovery
// document is published (auth.NewAwaitingDiscovery). Its diff from
// routes.golden is the list of routes that answer differently: the sign-in
// rows.
func TestNewHandlerRouteTableNotReady(t *testing.T) {
env := newRouteEnvOver(t, nil, auth.NewAwaitingDiscovery(authtest.NewSessionManager()))
golden.Compare(t, "testdata/routes-not-ready.golden", routeTable(t, env))
}
// TestNewHandlerUnavailableSignInIsStyled: a browser's /login before the
// discovery document is published renders the styled page with the heading
// "Sign-in unavailable" and one action, "Try again", back to /login (copy
// row 1).
func TestNewHandlerUnavailableSignInIsStyled(t *testing.T) {
env := newRouteEnvOver(t, nil, auth.NewAwaitingDiscovery(authtest.NewSessionManager()))
rec := env.serve(probe(http.MethodGet, "/login", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("/login before discovery = %d, want 503", rec.Code)
}
doc := htmltest.Parse(t, rec.Body.String())
if got := doc.One(t, "main h1").Text(); got != "Sign-in unavailable" {
t.Errorf("heading = %q, want Sign-in unavailable", got)
}
if got := doc.One(t, `main a[href="/login"]`).Text(); got != "Try again" {
t.Errorf("action = %q, want Try again", got)
}
if got := len(doc.Texts("main a")); got != 1 {
t.Errorf("the page has %d actions, want one", got)
}
}
// TestNewHandlerTrustsTheBaseURLOrigin: a cross-site POST from the base URL's
+11 -2
View File
@@ -321,8 +321,12 @@ func orgHasEntitlement(ctx context.Context, entitlementsQ entitlements.Querier,
return false
}
// Start builds the console's handler with NewHandler and serves it on
// cfg.Port until the server fails.
// Start sets up authentication, builds the console's handler with NewHandler
// and serves it on cfg.Port until the server fails. Authentication setup
// fetches nothing from the identity provider: Start runs the discovery loop
// (auth.Config.Discover) in the background after NewHandler and before it
// listens, and sign-in answers 503 until the loop has published a usable
// document.
func Start(ctx context.Context, cfg Config) error {
// The registry answers /domains/ask below, on the TLS handshake path of
// every proxy in front of the deployment. A missing querier would mean
@@ -345,6 +349,11 @@ func Start(ctx context.Context, cfg Config) error {
return err
}
// The identity provider's discovery document is fetched in the
// background, so the routes that need only the database are served
// before the provider answers; sign-in answers 503 until it has.
go authConfig.Discover(ctx)
// Create HTTP server
server := http.Server{
Addr: ":" + cfg.Port,
+348
View File
@@ -0,0 +1,348 @@
/ probe=/ pattern=/
anon: 302 loc=/login type=text/html h=nosniff,rid
/callback probe=/callback pattern=/callback
anon: 503 retry-after=5 type=text/html h1=Sign-in unavailable h=nosniff,rid
/login probe=/login pattern=/login
anon: 503 retry-after=5 type=text/html h1=Sign-in unavailable h=nosniff,rid
/logout-callback probe=/logout-callback pattern=/logout-callback
anon: 302 loc=/ type=text/html h=nosniff,rid
/operator/ probe=/operator/x pattern=/operator/
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
/partials/ probe=/partials/x pattern=/partials/
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 404 type=text/html h1=404 Not Found lead=The page you requested does not exist. h=nosniff,rid
/register probe=/register pattern=/register
anon: 503 retry-after=5 type=text/html h1=Registration unavailable h=nosniff,rid
/static/ probe=/static/app.css pattern=/static/
anon: 200 type=text/css h=nosniff,rid
member: 200 type=text/css h=nosniff,rid
/static/fake/ probe=/static/fake/app.js pattern=/static/fake/
anon: 200 type=text/javascript h=nosniff,rid
member: 200 type=text/javascript h=nosniff,rid
DELETE /operator/integrations/config-orphans/{key} probe=/operator/integrations/config-orphans/x pattern=DELETE /operator/integrations/config-orphans/{key}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
DELETE /operator/integrations/{integrationKey}/settings/{key} probe=/operator/integrations/x/settings/x pattern=DELETE /operator/integrations/{integrationKey}/settings/{key}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
DELETE /partials/operator/plan-ladders/{ladderID} probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000 pattern=DELETE /partials/operator/plan-ladders/{ladderID}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID} probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers/00000000-0000-0000-0000-000000000000 pattern=DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /billing probe=/billing pattern=GET /billing
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /domains probe=/domains pattern=GET /domains
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 302 loc=/#domains type=text/html h=nosniff,rid
GET /domains/ask probe=/domains/ask pattern=GET /domains/ask
anon: 400 type=text/html h1=400 Bad Request lead=This link is missing its domain. Ask whoever gave you the link to include a domain, or contact your operator. h=nosniff,rid
GET /hooks/card probe=/hooks/card pattern=GET /hooks/card
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 200 type= h=nosniff,rid
GET /hooks/panic probe=/hooks/panic pattern=GET /hooks/panic
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 500 type=text/html h1=500 Internal Server Error lead=An unexpected error occurred. Try again. h=nosniff,rid
GET /logout probe=/logout pattern=GET /logout
anon: 303 loc=/ type=text/html h=nosniff,rid
GET /operator probe=/operator pattern=GET /operator
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/billing/accounts probe=/operator/billing/accounts pattern=GET /operator/billing/accounts
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/billing/invoices probe=/operator/billing/invoices pattern=GET /operator/billing/invoices
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/billing/invoices/{invoiceID} probe=/operator/billing/invoices/00000000-0000-0000-0000-000000000000 pattern=GET /operator/billing/invoices/{invoiceID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/billing/payments probe=/operator/billing/payments pattern=GET /operator/billing/payments
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/billing/subscriptions probe=/operator/billing/subscriptions pattern=GET /operator/billing/subscriptions
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/domains probe=/operator/domains pattern=GET /operator/domains
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/entitlement-sets probe=/operator/entitlement-sets pattern=GET /operator/entitlement-sets
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/entitlement-sets/new probe=/operator/entitlement-sets/new pattern=GET /operator/entitlement-sets/new
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/entitlement-sets/{setID} probe=/operator/entitlement-sets/00000000-0000-0000-0000-000000000000 pattern=GET /operator/entitlement-sets/{setID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/grants probe=/operator/grants pattern=GET /operator/grants
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/integrations probe=/operator/integrations pattern=GET /operator/integrations
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/integrations/stripe probe=/operator/integrations/stripe pattern=GET /operator/integrations/stripe
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/integrations/{integrationKey}/settings probe=/operator/integrations/x/settings pattern=GET /operator/integrations/{integrationKey}/settings
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/lookup probe=/operator/lookup pattern=GET /operator/lookup
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/org-types probe=/operator/org-types pattern=GET /operator/org-types
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/organizations probe=/operator/organizations pattern=GET /operator/organizations
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/organizations/{orgID} probe=/operator/organizations/00000000-0000-0000-0000-000000000000 pattern=GET /operator/organizations/{orgID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/persons probe=/operator/persons pattern=GET /operator/persons
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/persons/{personID} probe=/operator/persons/00000000-0000-0000-0000-000000000000 pattern=GET /operator/persons/{personID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/plan-ladders probe=/operator/plan-ladders pattern=GET /operator/plan-ladders
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/plan-ladders/new probe=/operator/plan-ladders/new pattern=GET /operator/plan-ladders/new
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/plan-ladders/{ladderID} probe=/operator/plan-ladders/00000000-0000-0000-0000-000000000000 pattern=GET /operator/plan-ladders/{ladderID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/products probe=/operator/products pattern=GET /operator/products
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/products/new probe=/operator/products/new pattern=GET /operator/products/new
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/products/{productID} probe=/operator/products/00000000-0000-0000-0000-000000000000 pattern=GET /operator/products/{productID}
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /operator/setup probe=/operator/setup pattern=GET /operator/setup
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /partials/domains/claims probe=/partials/domains/claims pattern=GET /partials/domains/claims
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/domains/claims/{claimID} probe=/partials/domains/claims/00000000-0000-0000-0000-000000000000 pattern=GET /partials/domains/claims/{claimID}
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/addons probe=/partials/member/addons pattern=GET /partials/member/addons
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/entitlements probe=/partials/member/entitlements pattern=GET /partials/member/entitlements
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/invoices probe=/partials/member/invoices pattern=GET /partials/member/invoices
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/invoices/{invoiceID} probe=/partials/member/invoices/00000000-0000-0000-0000-000000000000 pattern=GET /partials/member/invoices/{invoiceID}
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/plans probe=/partials/member/plans pattern=GET /partials/member/plans
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/member/plans/switch/preview probe=/partials/member/plans/switch/preview pattern=GET /partials/member/plans/switch/preview
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/operator/organizations/{orgID}/enrollment probe=/partials/operator/organizations/00000000-0000-0000-0000-000000000000/enrollment pattern=GET /partials/operator/organizations/{orgID}/enrollment
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /partials/operator/products/name-check probe=/partials/operator/products/name-check pattern=GET /partials/operator/products/name-check
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /partials/operator/products/rule-check probe=/partials/operator/products/rule-check pattern=GET /partials/operator/products/rule-check
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /partials/operator/products/{productID}/readiness probe=/partials/operator/products/00000000-0000-0000-0000-000000000000/readiness pattern=GET /partials/operator/products/{productID}/readiness
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /partials/workspaces probe=/partials/workspaces pattern=GET /partials/workspaces
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /partials/workspaces/create-form probe=/partials/workspaces/create-form pattern=GET /partials/workspaces/create-form
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /products probe=/products pattern=GET /products
anon: 302 loc=/login type=text/html h=nosniff,rid
POST /billing/checkout probe=/billing/checkout pattern=POST /billing/checkout
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /hooks/test probe=/hooks/test pattern=POST /hooks/test
anon: 204 h=nosniff,rid
xsite: 204 h=nosniff,rid
member: 204 h=nosniff,rid
POST /logout probe=/logout pattern=POST /logout
anon: 303 loc=/ h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /operator/integrations/{integrationKey}/settings probe=/operator/integrations/x/settings pattern=POST /operator/integrations/{integrationKey}/settings
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/domains/claims/{claimID}/cancel probe=/partials/domains/claims/00000000-0000-0000-0000-000000000000/cancel pattern=POST /partials/domains/claims/{claimID}/cancel
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/domains/claims/{claimID}/check probe=/partials/domains/claims/00000000-0000-0000-0000-000000000000/check pattern=POST /partials/domains/claims/{claimID}/check
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/domains/claims/{claimID}/release probe=/partials/domains/claims/00000000-0000-0000-0000-000000000000/release pattern=POST /partials/domains/claims/{claimID}/release
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/cancel probe=/partials/member/plans/cancel pattern=POST /partials/member/plans/cancel
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/keep probe=/partials/member/plans/keep pattern=POST /partials/member/plans/keep
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/switch probe=/partials/member/plans/switch pattern=POST /partials/member/plans/switch
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/operator/domains/{claimID}/force-release probe=/partials/operator/domains/00000000-0000-0000-0000-000000000000/force-release pattern=POST /partials/operator/domains/{claimID}/force-release
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/entitlement-sets probe=/partials/operator/entitlement-sets pattern=POST /partials/operator/entitlement-sets
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/entitlement-sets/{setID}/recompute/retry probe=/partials/operator/entitlement-sets/00000000-0000-0000-0000-000000000000/recompute/retry pattern=POST /partials/operator/entitlement-sets/{setID}/recompute/retry
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/entitlement-sets/{setID}/rules probe=/partials/operator/entitlement-sets/00000000-0000-0000-0000-000000000000/rules pattern=POST /partials/operator/entitlement-sets/{setID}/rules
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/entitlement-sets/{setID}/rules/apply probe=/partials/operator/entitlement-sets/00000000-0000-0000-0000-000000000000/rules/apply pattern=POST /partials/operator/entitlement-sets/{setID}/rules/apply
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/grants/{grantID}/revoke-and-transition probe=/partials/operator/grants/00000000-0000-0000-0000-000000000000/revoke-and-transition pattern=POST /partials/operator/grants/{grantID}/revoke-and-transition
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/integrations/stripe/environment-check probe=/partials/operator/integrations/stripe/environment-check pattern=POST /partials/operator/integrations/stripe/environment-check
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/integrations/stripe/public-details probe=/partials/operator/integrations/stripe/public-details pattern=POST /partials/operator/integrations/stripe/public-details
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/org-types/{orgType}/default-change probe=/partials/operator/org-types/x/default-change pattern=POST /partials/operator/org-types/{orgType}/default-change
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/org-types/{orgType}/default-change/preview probe=/partials/operator/org-types/x/default-change/preview pattern=POST /partials/operator/org-types/{orgType}/default-change/preview
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/org-types/{orgType}/plan-acts/{actID}/retry probe=/partials/operator/org-types/x/plan-acts/00000000-0000-0000-0000-000000000000/retry pattern=POST /partials/operator/org-types/{orgType}/plan-acts/{actID}/retry
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/organizations/{orgID}/grant/create probe=/partials/operator/organizations/00000000-0000-0000-0000-000000000000/grant/create pattern=POST /partials/operator/organizations/{orgID}/grant/create
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/organizations/{orgID}/grants/ledger probe=/partials/operator/organizations/00000000-0000-0000-0000-000000000000/grants/ledger pattern=POST /partials/operator/organizations/{orgID}/grants/ledger
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/organizations/{orgID}/grants/{grantID}/manage probe=/partials/operator/organizations/00000000-0000-0000-0000-000000000000/grants/00000000-0000-0000-0000-000000000000/manage pattern=POST /partials/operator/organizations/{orgID}/grants/{grantID}/manage
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders probe=/partials/operator/plan-ladders pattern=POST /partials/operator/plan-ladders
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/reorder probe=/partials/operator/plan-ladders/reorder pattern=POST /partials/operator/plan-ladders/reorder
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/plan-acts/00000000-0000-0000-0000-000000000000/retry pattern=POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers/reorder pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers/reorder/preview pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers/00000000-0000-0000-0000-000000000000/remove pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers/00000000-0000-0000-0000-000000000000/remove/preview pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/products probe=/partials/operator/products pattern=POST /partials/operator/products
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/products/{productID}/prices probe=/partials/operator/products/00000000-0000-0000-0000-000000000000/prices pattern=POST /partials/operator/products/{productID}/prices
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/products/{productID}/prices/{priceID}/deactivate probe=/partials/operator/products/00000000-0000-0000-0000-000000000000/prices/00000000-0000-0000-0000-000000000000/deactivate pattern=POST /partials/operator/products/{productID}/prices/{priceID}/deactivate
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/products/{productID}/prices/{priceID}/make-default probe=/partials/operator/products/00000000-0000-0000-0000-000000000000/prices/00000000-0000-0000-0000-000000000000/make-default pattern=POST /partials/operator/products/{productID}/prices/{priceID}/make-default
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/products/{productID}/sync-stripe probe=/partials/operator/products/00000000-0000-0000-0000-000000000000/sync-stripe pattern=POST /partials/operator/products/{productID}/sync-stripe
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/setup/dismiss probe=/partials/operator/setup/dismiss pattern=POST /partials/operator/setup/dismiss
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/workspaces probe=/partials/workspaces pattern=POST /partials/workspaces
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/workspaces/{workspaceID}/switch probe=/partials/workspaces/00000000-0000-0000-0000-000000000000/switch pattern=POST /partials/workspaces/{workspaceID}/switch
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
PUT /partials/operator/entitlement-sets/{setID} probe=/partials/operator/entitlement-sets/00000000-0000-0000-0000-000000000000 pattern=PUT /partials/operator/entitlement-sets/{setID}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
PUT /partials/operator/plan-ladders/{ladderID} probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000 pattern=PUT /partials/operator/plan-ladders/{ladderID}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
PUT /partials/operator/products/{productID} probe=/partials/operator/products/00000000-0000-0000-0000-000000000000 pattern=PUT /partials/operator/products/{productID}
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
extra: an unknown path GET /no-such-page pattern=/
anon: 404 type=text/html h1=404 Not Found lead=The page you requested does not exist. h=nosniff,rid
extra: an unknown path from htmx GET /no-such-page pattern=/
anon: 404 type=text/plain text=The page you requested does not exist. h=nosniff,rid
extra: an unknown path, compressed GET /no-such-page pattern=/
anon: 404 h=nosniff,rid,gzip
extra: a POST to a GET route POST /domains/ask pattern=/
anon: 404 type=text/html h1=404 Not Found lead=The page you requested does not exist. h=nosniff,rid
extra: a GET to a POST route GET /billing/checkout pattern=/
anon: 404 type=text/html h1=404 Not Found lead=The page you requested does not exist. h=nosniff,rid
extra: a body one byte over 1 MiB POST /partials/workspaces pattern=POST /partials/workspaces
anon: 413 type=text/plain text=Request body too large h=nosniff,rid
extra: a mount without static files GET /static/nostatic/app.js pattern=/static/
anon: 404 type=text/plain text=404 page not found h=nosniff,rid
extra: a cross-origin read GET /static/app.css pattern=/static/
anon: 200 type=text/css h=nosniff,rid,acao=*
bare: POST /billing/checkout pattern=/
anon: 404 type=text/html h1=404 Not Found lead=The page you requested does not exist. h=nosniff,rid
@@ -156,10 +156,10 @@ A new leaf package, `internal/retryloop`, runs an attempt function until it succ
| Cap | 15 seconds |
| Jitter | each wait plus a uniform random fraction up to half of it |
| Wake | ends the wait, no sooner than 1 second after the last attempt began; it does not end a wait that follows an `AtCap` result |
| A result that says "retry at the cap" (D4, D10) | the next wait is the cap |
| A result that says "retry at the cap" (D4, D10) | the next wait's base is the cap, plus the jitter |
| Log: first failure | at once |
| Log: error text differs from the last logged | at once, at most one line per 30 seconds |
| Log: otherwise | one line per 5 minutes, carrying the attempts since the last line |
| Log: otherwise | one line per 5 minutes; every line's `attempts` is the number of attempts so far, so two lines' difference is the attempts between them |
| Level | Warn until the escalation time, Error after; the first line after it is written at once; a failure's own level is a floor (Error for copy rows 10, 15 and 16, from the first line) |
| Escalation time | Temporal: `temporal-connect-timeout` (90 seconds by default); discovery: 90 seconds |
| Success | one Info line with the attempts and the elapsed time |
@@ -6,7 +6,7 @@ When `temporal-host` is configured, `member-console start` SHALL connect to Temp
One connection attempt SHALL be the dial, bounded to twenty seconds and respecting context cancellation, followed by the worker preconditions, the worker's construction and its start. An attempt that fails at any of these steps SHALL close the client it dialled and SHALL be retried, except the refusal of Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open, which keeps the client and reads its list again. Retries SHALL wait half a second after the first failure, doubling after each further failure up to a cap of fifteen seconds, each wait lengthened by a random fraction of up to half of itself, so that simultaneous consoles do not reconnect in lockstep.
The retries SHALL be logged with decay: the first failure at once; a failure whose error text differs from the last one logged at once, but at most one such line per thirty seconds; and otherwise one line per five minutes carrying the number of attempts since the last line. Each line SHALL carry the attempts, the time elapsed since start, the next wait and the error. The lines SHALL be Warn until `temporal-connect-timeout` has passed since start and Error afterwards, and the first line after that time SHALL be written when it passes. The default of 90 seconds exceeds the identity provider's key-refresh window, so that a cold-start authorization failure, where Temporal is reachable but has not yet loaded the provider's signing keys, resolves before the log turns to Error. A refusal of Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open, and a failure to build or start the worker, SHALL be logged at Error from the first line, through the same decay. The connection, once made, SHALL be logged once with the attempts and the time it took.
The retries SHALL be logged with decay: the first failure at once; a failure whose error text differs from the last one logged at once, but at most one such line per thirty seconds; and otherwise one line per five minutes. Each line SHALL carry the number of attempts so far, the time elapsed since start, the next wait and the error. The lines SHALL be Warn until `temporal-connect-timeout` has passed since start and Error afterwards, and the first line after that time SHALL be written when it passes. The default of 90 seconds exceeds the identity provider's key-refresh window, so that a cold-start authorization failure, where Temporal is reachable but has not yet loaded the provider's signing keys, resolves before the log turns to Error. A refusal of Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open, and a failure to build or start the worker, SHALL be logged at Error from the first line, through the same decay. The connection, once made, SHALL be logged once with the attempts and the time it took.
After a successful attempt, start SHALL publish the connection to every handler (Requirement: A Temporal-backed action reads the connection when it runs) and only then run each integration's startup hook, the entitlement recompute and plan act pollers, the desired-state relay and read-back schedules, the Stripe environment check and public-details read, and the sweep schedules, so that every sweep those hooks run sees the rows recorded while Temporal was not connected.
@@ -42,14 +42,14 @@ Sections 1 to 7 are one commit each, and their items are that commit's parts; se
## 3. A retry loop, and sign-in that waits for the discovery document
- [ ] 3.1 `internal/retryloop` (new package, design D17): `Run(ctx, policy, attempt)` calls `attempt(ctx)` until it returns nil or `ctx` ends, and returns nil or `ctx.Err()`. `Policy` carries the first wait, the cap, the escalation time, an optional wake channel, the minimum gap after an attempt before a wake counts, the default message for a failed attempt that returns a plain error, the message for the success, the base attributes every line carries, a logger, and an injectable clock and random source (the real ones when unset). An attempt returns a plain error, or a `retryloop.Failure{Message, Level, Err}` carrying its own message and a level floor (Error for copy rows 10, 15 and 16); `AtCap(err)` marks an error after which the next wait is the cap, and a wake does not end that wait. Every line goes through the decay: no caller logs from inside an attempt (design D17). The decay and escalation are D17's table. The package doc comment states the numbers and the bound on lines per hour.
- [ ] 3.2 `internal/auth/auth.go` (design D3, D4): two unexported immutable values behind two `atomic.Pointer`s in `Config`, with a wake channel of buffer one: the verifier (the `*oauth2.Config`, the verifier, the provider, the revocation endpoint) and the sign-in value, `signInState{verifier *discovered; endSession string}`, which holds the end-session endpoint and a pointer to the verifier value it was published with, so that a request that loads the sign-in value never meets a verifier from another document. `NewAwaitingDiscovery(sessionManager)` returns a `Config` with empty holders; `Setup` builds its `Config` with it, reads from viper once every key the loop needs, and no longer calls `oidc.NewProvider`. `providerEndpoints(provider, issuer)` compares the document's `issuer` claim with the configured one and returns a typed `issuerMismatchError`; with a matching issuer it returns both endpoints, and a missing end-session endpoint is its own error, returned together with the revocation endpoint the verifier needs (today's error path returns it as `""`, `auth.go:149-150`); the provider is built with `oidc.InsecureIssuerURLContext(ctx, issuer)` so that go-oidc's own untyped issuer error cannot occur, and ID tokens are still checked against the configured issuer. `(*Config).Discover(ctx)` runs `retryloop.Run` with D17's numbers and a ninety-second escalation: each attempt builds the provider under a context carrying a dedicated `*http.Client` (`Timeout` from the package variable `discoveryAttemptTimeout`, ten seconds, which a test shortens) and a deadline of the same length, and stores only inside the matching-issuer branch: the verifier once (if it is not stored), logging copy 12 when the document has no revocation endpoint, and then, when the same document carries `end_session_endpoint`, the sign-in value once, after the verifier and pointing at the stored verifier. A document whose issuer does not match stores neither, whatever it carries. With both stored the loop logs copy 11 and returns. An `issuerMismatchError` or a missing end-session endpoint is returned as a `retryloop.Failure` marked `AtCap` with copy 10's message; any other failure is copy 9's message. `verifier()` and `signIn()` return the holders' values for a `Config` built by `NewAwaitingDiscovery`. For a `Config` without holders (every hand-built one) both always return a value, never nil, copying the exported fields as they are: a nil `OAuthConfig`, an empty `EndSessionEndpoint` and a nil `Verifier` included. The checks downstream of the gates already test `OAuthConfig` (`auth.go:839`, `:882`, `:1106`), now on the value, so the gates fire only for a `Config` built by `NewAwaitingDiscovery`, which is what "exactly as it does today" needs. The method `signIn()` keeps its name, because the package's test helper `signIn` (`auth_session_person_test.go:51`) is a package-level function and a method name is not in package scope; their doc comments say why both exist.
- [ ] 3.3 The handlers read each pointer they need once per request. `/login`, `/register`, `/callback` and `POST /logout` load the sign-in pointer and fall back to the verifier pointer only when it is nil, so the pair they use always comes from one document; re-derivation loads the verifier pointer. The gates and the logout branch test the pointer and not the endpoint's string, so a hand-built `Config` with an empty `EndSessionEndpoint` keeps today's answers. `LoginHandler`, `CallbackHandler` and `RegistrationHandler` begin with a gate: with the sign-in value missing, wake the loop with a non-blocking send, set `Retry-After: 5`, and answer 503 through `showFailure` with copy 1 (`/login` and `/callback`) or copy 2 (`/register`), before anything touches the session. `LogoutHandler` with no sign-in value destroys the session, revokes the refresh tokens when the verifier is published, logs copy 20, and answers `HX-Redirect: /` with 200 for an htmx request and 303 to `/` otherwise (design D5); with the sign-in value it runs as today, passing the value's verifier to `idTokenHint` and `revokeRefreshTokens`, which read nothing from `c`'s exported discovery fields and dereference nothing the value does not hold. `refreshIdentity` and `rederive` read the verifier value and keep today's nil-`Verifier` check (`auth.go:1106`), now applied to that value; with none, the session keeps its state and nothing is asked of the provider. The comment on `EndSessionEndpoint` and the "Setup guarantees one" log line of `LogoutHandler` are reworded to say where the endpoint comes from.
- [ ] 3.4 `internal/server/server.go`: `Start` runs `go authConfig.Discover(ctx)` after `NewHandler` and before `ListenAndServe`; the doc comment of `Start` says so. Nothing else in the boot order changes in this section.
- [ ] 3.5 `internal/retryloop` tests, each with a fake clock and a seeded source: `TestRunWaitsFollowThePolicy` (the exact waits of eight failures that the seeded source yields, each the base wait plus the seeded fraction of it, the cap reached and kept; a range check would accept zero jitter); `TestRunWakeEndsTheWait` (a wake during a fifteen-second wait starts the next attempt one second after the last began, and a wake within that second waits for the second); `TestRunAtCapWaitsTheCap` (the wait after an `AtCap` result is the cap, and a wake during it does not end it); `TestRunStopsWhenTheContextEnds` (returns `context.Canceled`, no further attempt); `TestRunLogDecay`, one row per pattern through a named function: an hour of one error (at most fourteen lines; the first is Warn, every line from ninety seconds on is Error, and the first Error line is written when the escalation time passes), an hour of an error whose text changes on every attempt (no two changed-text lines within thirty seconds), a `Failure` with level Error (Error from the first line, and the hour's count stays within the bound), and three failures then a success (three lines, then one Info line with `attempts` 4 and the elapsed time). Every line carries `attempts`, `elapsed`, `retry_in` and `error`.
- [ ] 3.6 `internal/auth` tests, with an `httptest` identity provider whose answer the test switches (`discoverytest` helpers in the test file, building on the fake discovery document of `logout_test.go`): `TestSignInWaitsForDiscovery`: while the fake answers 503, `/login` answers 503, `Retry-After: 5`, copy 1's heading as plain text without a renderer, no `Set-Cookie`, and the session store holds no new entry (the test keeps its own reference to the `memstore.MemStore` it built, because from section 4 `NewAwaitingDiscovery` replaces `sm.Store` with the tagged store and a read through `cfg.SessionManager.Store` no longer reaches it); after the switch to a usable document, a `/login` within the loop's wait answers 302 to the fake's authorization endpoint; `/callback` and `/register` give copy 1 and copy 2 before the switch. `TestDiscoveryKeepsAnUnusableDocumentOut`, one row per document: no `end_session_endpoint` (the verifier is published, the sign-in value is not, and `/login` still answers 503), and an issuer that is not the configured one, in a document that carries `end_session_endpoint` (neither value is published, `/login` still answers 503, and the error is the typed `issuerMismatchError`); each logs copy 10 at Error from the first line, and the same fake switched to a usable document is published without a new `Config`. `TestDiscoveryAttemptEnds`, one row per fake, with `discoveryAttemptTimeout` shortened to 200 milliseconds: a fake that accepts the document request and never answers (the attempt's line is logged within a second), and a usable document whose `jwks_uri` accepts and never answers (a `Verify` of an ID token, called with `context.Background()` in a goroutine, returns within a second, and the test fails with "Verify outlasted the attempt timeout" otherwise; a `Verify` under a deadline of its own returns at that deadline whatever client the key set uses, so only a `Verify` with no deadline shows that the dedicated client bounds the fetch). `TestSignInRequestWakesDiscovery`: the loop with an injected clock (an unexported package variable beside `discoveryAttemptTimeout` holds the clock `Discover` hands to the loop) waiting out a fifteen-second backoff; a `/login` starts the next attempt without advancing the clock past one second. `TestSignOutWithoutAnEndSessionEndpoint`, one row per state: before publication (the session is gone from the store, the answer is 303 to `/` and 200 with `HX-Redirect: /` for an htmx request, copy 20 is logged, and the fake received no request after the test began counting), and with the verifier published and no end-session endpoint (the same answers, and the fake received a revocation request carrying the session's refresh token). `TestSignedInSessionPassesBeforeDiscovery`: a session whose re-derivation is due, served through `Middleware` before publication, reaches the handler with its roles, and the fake received no request. `TestRolesAreRederivedWhileTheDocumentIsUnusableForSignIn`: the fake serves a document with the configured issuer and no `end_session_endpoint`, the role of a signed-in session is removed at the fake's token endpoint, and the session's next request after the interval carries no such role while `/login` answers 503. `TestDiscoveryPublishesUnderLoad`: twenty goroutines request `/login` and a signed-in page through `Middleware` while the fake switches; every answer is 503 or 302 for `/login` and 200 for the page, and the test runs under `-race` (item 3.10). Existing tests change with the accessors, and each keeps its result because a `Config` without holders always returns values: the tests that call a gated handler through `newTestConfig()`, which is `&Config{SessionManager: sm}` with no `OAuthConfig` (`TestRegistrationHandlerRotatesTheSessionToken` in `auth_session_rotation_test.go` still gets its rotated token, `TestAStateCarryingNewlinesStaysOneLogRecord` in `auth_logging_test.go` still gets 400, and `auth_failure_page_test.go` at `:60`, `:92`, `:138`, `:161` and `:179` still get 400, because `CallbackHandler` refuses on the state or on the provider's `error` before it reads `OAuthConfig`), and three more: `auth_login_test.go` (an `OAuthConfig` with no `EndSessionEndpoint` still gets 302 at `/login`), `logout_test.go` (an empty `EndSessionEndpoint` still answers 500, `:530`) and `new_handler_test.go`'s `routeAuth` (no `Verifier`, so re-derivation keeps today's nil check). `TestProviderEndpointsComeFromDiscovery` (`logout_test.go:486`) is rewritten for `providerEndpoints(provider, issuer)`: its "no end-session endpoint" subtest wants the missing-endpoint error together with the revocation endpoint, and a new subtest wants `issuerMismatchError`.
- [ ] 3.7 `internal/server`: `answer()` (the route table's recorder) records a `Retry-After` header when a response carries one, so `routes.golden` does not change. `TestNewHandlerRouteTableNotReady` builds the route environment with `auth.NewAwaitingDiscovery` over the test's session manager and writes `testdata/routes-not-ready.golden` with the rows of `TestNewHandlerRouteTable`. Its diff from `routes.golden` is the sign-in rows only: `/login`, `/register` and `/callback` answer 503 with `Retry-After: 5` and copy 1 or 2, and `POST /logout` lands on `/`. `TestNewHandlerUnavailableSignInIsStyled`: a browser `/login` before publication renders the styled page with copy 1's heading and one action, `Try again`, to `/login`.
- [ ] 3.8 Breaks:
- [x] 3.1 `internal/retryloop` (new package, design D17): `Run(ctx, policy, attempt)` calls `attempt(ctx)` until it returns nil or `ctx` ends, and returns nil or `ctx.Err()`. `Policy` carries the first wait, the cap, the escalation time, an optional wake channel, the minimum gap after an attempt before a wake counts, the default message for a failed attempt that returns a plain error, the message for the success, the base attributes every line carries, a logger, and an injectable clock and random source (the real ones when unset). An attempt returns a plain error, or a `retryloop.Failure{Message, Level, Err}` carrying its own message and a level floor (Error for copy rows 10, 15 and 16); `AtCap(err)` marks an error after which the next wait's base is the cap (the jitter still applies), and a wake does not end that wait. Every line goes through the decay: no caller logs from inside an attempt (design D17). The decay and escalation are D17's table. The package doc comment states the numbers and the bound on lines per hour.
- [x] 3.2 `internal/auth/auth.go` (design D3, D4): two unexported immutable values behind two `atomic.Pointer`s in `Config`, with a wake channel of buffer one: the verifier (the `*oauth2.Config`, the verifier, the provider, the revocation endpoint) and the sign-in value, `signInState{verifier *discovered; endSession string}`, which holds the end-session endpoint and a pointer to the verifier value it was published with, so that a request that loads the sign-in value never meets a verifier from another document. `NewAwaitingDiscovery(sessionManager)` returns a `Config` with empty holders; `Setup` builds its `Config` with it, reads from viper once every key the loop needs, and no longer calls `oidc.NewProvider`. `providerEndpoints(provider, issuer)` compares the document's `issuer` claim with the configured one and returns a typed `issuerMismatchError`; with a matching issuer it returns both endpoints, and a missing end-session endpoint is its own error, returned together with the revocation endpoint the verifier needs (today's error path returns it as `""`, `auth.go:149-150`); the provider is built with `oidc.InsecureIssuerURLContext(ctx, issuer)` so that go-oidc's own untyped issuer error cannot occur, and ID tokens are still checked against the configured issuer. `(*Config).Discover(ctx)` runs `retryloop.Run` with D17's numbers and a ninety-second escalation: each attempt builds the provider under a context carrying a dedicated `*http.Client` (`Timeout` from the package variable `discoveryAttemptTimeout`, ten seconds, which a test shortens) and a deadline of the same length, and stores only inside the matching-issuer branch: the verifier once (if it is not stored), logging copy 12 when the document has no revocation endpoint, and then, when the same document carries `end_session_endpoint`, the sign-in value once, after the verifier and pointing at the stored verifier. A document whose issuer does not match stores neither, whatever it carries. With both stored the loop logs copy 11 and returns. An `issuerMismatchError` or a missing end-session endpoint is returned as a `retryloop.Failure` marked `AtCap` with copy 10's message; any other failure is copy 9's message. `verifier()` and `signIn()` return the holders' values for a `Config` built by `NewAwaitingDiscovery`. For a `Config` without holders (every hand-built one) both always return a value, never nil, copying the exported fields as they are: a nil `OAuthConfig`, an empty `EndSessionEndpoint` and a nil `Verifier` included. The checks downstream of the gates already test `OAuthConfig` (`auth.go:839`, `:882`, `:1106`), now on the value, so the gates fire only for a `Config` built by `NewAwaitingDiscovery`, which is what "exactly as it does today" needs. The method `signIn()` keeps its name, because the package's test helper `signIn` (`auth_session_person_test.go:51`) is a package-level function and a method name is not in package scope; their doc comments say why both exist.
- [x] 3.3 The handlers read each pointer they need once per request. `/login`, `/register`, `/callback` and `POST /logout` load the sign-in pointer and fall back to the verifier pointer only when it is nil, so the pair they use always comes from one document; re-derivation loads the verifier pointer. The gates and the logout branch test the pointer and not the endpoint's string, so a hand-built `Config` with an empty `EndSessionEndpoint` keeps today's answers. `LoginHandler`, `CallbackHandler` and `RegistrationHandler` begin with a gate: with the sign-in value missing, wake the loop with a non-blocking send, set `Retry-After: 5`, and answer 503 through `showFailure` with copy 1 (`/login` and `/callback`) or copy 2 (`/register`), before anything touches the session. `LogoutHandler` with no sign-in value destroys the session, revokes the refresh tokens when the verifier is published, logs copy 20, and answers `HX-Redirect: /` with 200 for an htmx request and 303 to `/` otherwise (design D5); with the sign-in value it runs as today, passing the value's verifier to `idTokenHint` and `revokeRefreshTokens`, which read nothing from `c`'s exported discovery fields and dereference nothing the value does not hold. `refreshIdentity` and `rederive` read the verifier value and keep today's nil-`Verifier` check (`auth.go:1106`), now applied to that value; with none, the session keeps its state and nothing is asked of the provider. The comment on `EndSessionEndpoint` and the "Setup guarantees one" log line of `LogoutHandler` are reworded to say where the endpoint comes from.
- [x] 3.4 `internal/server/server.go`: `Start` runs `go authConfig.Discover(ctx)` after `NewHandler` and before `ListenAndServe`; the doc comment of `Start` says so. Nothing else in the boot order changes in this section.
- [x] 3.5 `internal/retryloop` tests, each with a fake clock and a seeded source: `TestRunWaitsFollowThePolicy` (the exact waits of eight failures that the seeded source yields, each the base wait plus the seeded fraction of it, the cap reached and kept; a range check would accept zero jitter); `TestRunWakeEndsTheWait` (a wake during a fifteen-second wait starts the next attempt one second after the last began, and a wake within that second waits for the second); `TestRunAtCapWaitsTheCap` (the wait after an `AtCap` result is the cap plus the seeded jitter, and a wake during it does not end it); `TestRunStopsWhenTheContextEnds` (returns `context.Canceled`, no further attempt); `TestRunLogDecay`, one row per pattern through a named function: an hour of one error (at most fourteen lines; the first is Warn, every line from ninety seconds on is Error, and the first Error line is written when the escalation time passes), an hour of an error whose text changes on every attempt (no two changed-text lines within thirty seconds), a `Failure` with level Error (Error from the first line, and the hour's count stays within the bound), and three failures then a success (three lines, then one Info line with `attempts` 4 and the elapsed time). Every line carries `attempts`, `elapsed`, `retry_in` and `error`.
- [x] 3.6 `internal/auth` tests, with an `httptest` identity provider whose answer the test switches (`discoverytest` helpers in the test file, building on the fake discovery document of `logout_test.go`): `TestSignInWaitsForDiscovery`: while the fake answers 503, `/login` answers 503, `Retry-After: 5`, copy 1's heading as plain text without a renderer, no `Set-Cookie`, and the session store holds no new entry (the test keeps its own reference to the `memstore.MemStore` it built, because from section 4 `NewAwaitingDiscovery` replaces `sm.Store` with the tagged store and a read through `cfg.SessionManager.Store` no longer reaches it); after the switch to a usable document, a `/login` within the loop's wait answers 302 to the fake's authorization endpoint; `/callback` and `/register` give copy 1 and copy 2 before the switch. `TestDiscoveryKeepsAnUnusableDocumentOut`, one row per document: no `end_session_endpoint` (the verifier is published, the sign-in value is not, and `/login` still answers 503), and an issuer that is not the configured one, in a document that carries `end_session_endpoint` (neither value is published, `/login` still answers 503, and the error is the typed `issuerMismatchError`); each logs copy 10 at Error from the first line, and the same fake switched to a usable document is published without a new `Config`. `TestDiscoveryAttemptEnds`, one row per fake, with `discoveryAttemptTimeout` shortened to 200 milliseconds: a fake that accepts the document request and never answers (the attempt's line is logged within a second), and a usable document whose `jwks_uri` accepts and never answers (a `Verify` of an ID token, called with `context.Background()` in a goroutine, returns within a second, and the test fails with "Verify outlasted the attempt timeout" otherwise; a `Verify` under a deadline of its own returns at that deadline whatever client the key set uses, so only a `Verify` with no deadline shows that the dedicated client bounds the fetch). `TestSignInRequestWakesDiscovery`: the loop with an injected clock (an unexported package variable beside `discoveryAttemptTimeout` holds the clock `Discover` hands to the loop) waiting out a fifteen-second backoff; a `/login` starts the next attempt without advancing the clock past one second. `TestSignOutWithoutAnEndSessionEndpoint`, one row per state: before publication (the session is gone from the store, the answer is 303 to `/` and 200 with `HX-Redirect: /` for an htmx request, copy 20 is logged, and the fake received no request after the test began counting), and with the verifier published and no end-session endpoint (the same answers, and the fake received a revocation request carrying the session's refresh token). `TestSignedInSessionPassesBeforeDiscovery`: a session whose re-derivation is due, served through `Middleware` before publication, reaches the handler with its roles, and the fake received no request. `TestRolesAreRederivedWhileTheDocumentIsUnusableForSignIn`: the fake serves a document with the configured issuer and no `end_session_endpoint`, the role of a signed-in session is removed at the fake's token endpoint, and the session's next request after the interval carries no such role while `/login` answers 503. `TestDiscoveryPublishesUnderLoad`: twenty goroutines request `/login` and a signed-in page through `Middleware` while the fake switches; every answer is 503 or 302 for `/login` and 200 for the page, and the test runs under `-race` (item 3.10). Existing tests change with the accessors, and each keeps its result because a `Config` without holders always returns values: the tests that call a gated handler through `newTestConfig()`, which is `&Config{SessionManager: sm}` with no `OAuthConfig` (`TestRegistrationHandlerRotatesTheSessionToken` in `auth_session_rotation_test.go` still gets its rotated token, `TestAStateCarryingNewlinesStaysOneLogRecord` in `auth_logging_test.go` still gets 400, and `auth_failure_page_test.go` at `:60`, `:92`, `:138`, `:161` and `:179` still get 400, because `CallbackHandler` refuses on the state or on the provider's `error` before it reads `OAuthConfig`), and three more: `auth_login_test.go` (an `OAuthConfig` with no `EndSessionEndpoint` still gets 302 at `/login`), `logout_test.go` (an empty `EndSessionEndpoint` still answers 500, `:530`) and `new_handler_test.go`'s `routeAuth` (no `Verifier`, so re-derivation keeps today's nil check). `TestProviderEndpointsComeFromDiscovery` (`logout_test.go:486`) is rewritten for `providerEndpoints(provider, issuer)`: its "no end-session endpoint" subtest wants the missing-endpoint error together with the revocation endpoint, and a new subtest wants `issuerMismatchError`.
- [x] 3.7 `internal/server`: `answer()` (the route table's recorder) records a `Retry-After` header when a response carries one, so `routes.golden` does not change. `TestNewHandlerRouteTableNotReady` builds the route environment with `auth.NewAwaitingDiscovery` over the test's session manager and writes `testdata/routes-not-ready.golden` with the rows of `TestNewHandlerRouteTable`. Its diff from `routes.golden` is the sign-in rows only: `/login`, `/register` and `/callback` answer 503 with `Retry-After: 5` and copy 1 or 2, and `POST /logout` lands on `/`. `TestNewHandlerUnavailableSignInIsStyled`: a browser `/login` before publication renders the styled page with copy 1's heading and one action, `Try again`, to `/login`.
- [x] 3.8 Breaks:
- `LoginHandler`'s gate after `RenewToken`: caught by `TestSignInWaitsForDiscovery` (a `Set-Cookie` is written);
- the gate without the wake: caught by `TestSignInRequestWakesDiscovery`;
- the gate answers through `http.Error` instead of `showFailure`: caught by `TestNewHandlerUnavailableSignInIsStyled`;
@@ -78,8 +78,8 @@ Sections 1 to 7 are one commit each, and their items are that commit's parts; se
- a wake that ignores the one-second gap: caught by `TestRunWakeEndsTheWait` (the gap is asserted there; `TestSignInRequestWakesDiscovery` has one wake);
- `answer()` without the `Retry-After` line: caught by `TestNewHandlerRouteTableNotReady` (`want_new` is `testdata/routes-not-ready.golden differs from what the code produced`, which `internal/testkit/golden/golden.go:42` prints only on a mismatch);
- `Start` without `go authConfig.Discover(ctx)`: no test of this section runs `Start`; its break runs with section 6's list (item 6.10).
- [ ] 3.9 `internal/embeds/static/error-handler.js`: the 5xx toast reads `Server error. Try again later.` (copy row 24). The check `filler-copy` cannot make, because it does not scan JavaScript, is `git grep "Please try again later" -- internal/embeds test` finding nothing after the edit; no test, golden or screen baseline carries the old string, so nothing else changes.
- [ ] 3.10 After `test/reset-test-db.sh`, `go test -race -p 1 -count=1 ./internal/retryloop/ ./internal/auth/ ./internal/server/` passes.
- [x] 3.9 `internal/embeds/static/error-handler.js`: the 5xx toast reads `Server error. Try again later.` (copy row 24). The check `filler-copy` cannot make, because it does not scan JavaScript, is `git grep "Please try again later" -- internal/embeds test` finding nothing after the edit; no test, golden or screen baseline carries the old string, so nothing else changes.
- [x] 3.10 After `test/reset-test-db.sh`, `go test -race -p 1 -count=1 ./internal/retryloop/ ./internal/auth/ ./internal/server/` passes.
## 4. The session store: a refusal stops the start, an outage answers 503