Refuse OIDC redirect downgrades; stop logging provider error text
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:
+28
-6
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user