Refuse OIDC redirect downgrades; stop logging provider error text
CI / ci (push) Successful in 1m27s
CI / ci (pull_request) Successful in 1m25s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)

The provider HTTP client now has a redirect policy: every redirect target
must pass secureurl.Check, and an https:// request never follows a
redirect to plain http://, so a 307/308 from the token endpoint cannot
resend the client secret in plaintext. Transport errors from the code
exchange no longer include the (possibly provider-supplied) URL.

The callback's provider-error path logs a fixed category instead of the
raw error and error_description query values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:49:44 -04:00
co-authored by Claude Opus 5.5
parent e8dafda534
commit bb0a9c3129
2 changed files with 153 additions and 7 deletions
+28 -6
View File
@@ -91,7 +91,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
return nil, err
}
client := &http.Client{Timeout: providerTimeout}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
@@ -149,6 +149,26 @@ func checkEndpoint(name, raw string) error {
return nil
}
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if err := secureurl.Check(req.URL); err != nil {
return fmt.Errorf("redirect refused: %w", err)
}
if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" {
return errors.New("redirect refused: https:// to plain http:// downgrade")
}
return nil
}
// Mount registers GET LoginPath and GET CallbackPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
@@ -203,8 +223,10 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if e := q.Get("error"); e != "" {
a.logger.WarnContext(ctx, "identity provider refused sign-in", "error", e, "description", q.Get("error_description"))
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.
a.logger.WarnContext(ctx, "identity provider returned an authorization error")
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
return
}
@@ -268,9 +290,9 @@ func exchangeError(err error) error {
case errors.As(err, &retrieve):
return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode)
case errors.As(err, &transport):
// Request failures never carry the response; the URL is the token
// endpoint from discovery.
return fmt.Errorf("exchange code: token request failed: %w", transport)
// Request failures never carry the response. The URL is dropped: after
// a refused redirect it is the provider-supplied Location.
return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err)
default:
return errors.New("exchange code: token endpoint returned an unusable response")
}