oidc, types: validate pkce.method when OIDC is active

This commit is contained in:
Kristoffer Dalby committed 2026-06-17 16:12:19 +02:00
1 parent 4f4e95fc80
commit 4b693a1320
3 files changed
+44 -1

No files matched your search

+7
View File
@@ -54,6 +54,7 @@ var (
"authenticated principal does not match any allowed user", "authenticated principal does not match any allowed user",
) )
errOIDCUnverifiedEmail = errors.New("authenticated principal has an unverified email") errOIDCUnverifiedEmail = errors.New("authenticated principal has an unverified email")
errInvalidPKCEMethod = errors.New("invalid pkce.method")
) )
// AuthInfo contains both auth ID and verifier information for OIDC validation. // AuthInfo contains both auth ID and verifier information for OIDC validation.
@@ -186,6 +187,12 @@ func (a *AuthProviderOIDC) authHandler(
case types.PKCEMethodPlain: case types.PKCEMethodPlain:
// oauth2 does not have a plain challenge option, so we add it manually // oauth2 does not have a plain challenge option, so we add it manually
extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier)) extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier))
default:
// An unknown method must not silently emit no challenge: a
// verifier was generated and is sent at token exchange, so a
// missing challenge degrades to no-PKCE without anyone noticing.
httpError(writer, NewHTTPError(http.StatusInternalServerError, "internal server error", fmt.Errorf("%w: %q", errInvalidPKCEMethod, a.cfg.PKCE.Method)))
return
} }
} }
+5 -1
View File
@@ -558,7 +558,11 @@ func validateServerConfig() error {
// Removed: oidc.expiry -> node.expiry // Removed: oidc.expiry -> node.expiry
depr.fatalIfSet("oidc.expiry", "node.expiry") depr.fatalIfSet("oidc.expiry", "node.expiry")
if viper.GetBool("oidc.enabled") { // OIDC is activated by setting oidc.issuer (see app.go), not by a
// dedicated oidc.enabled key. Gate PKCE method validation on the real
// activation condition so an invalid method fails at startup instead of
// silently disabling PKCE at runtime.
if viper.GetString("oidc.issuer") != "" {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method")) err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
if err != nil { if err != nil {
return err return err
+32
View File
@@ -412,6 +412,38 @@ tls_letsencrypt_challenge_type: TLS-ALPN-01
require.NoError(t, err) require.NoError(t, err)
} }
func TestPKCEMethodValidation(t *testing.T) {
tmpDir := t.TempDir()
// OIDC is active (issuer set) with PKCE enabled and an invalid method.
// There is no oidc.enabled key. validateServerConfig must reject the
// invalid method rather than silently skipping the check.
configYaml := []byte(`---
noise:
private_key_path: noise_private.key
server_url: http://127.0.0.1:8080
dns:
override_local_dns: false
oidc:
issuer: https://idp.example.com
client_id: headscale
pkce:
enabled: true
method: S256-typo
`)
configFilePath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configFilePath, configYaml, 0o600)
require.NoError(t, err)
err = LoadConfig(tmpDir, false)
require.NoError(t, err)
err = validateServerConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), errInvalidPKCEMethod.Error())
}
// OK // OK
// server_url: headscale.com, base: clients.headscale.com // server_url: headscale.com, base: clients.headscale.com
// server_url: headscale.com, base: headscale.net // server_url: headscale.com, base: headscale.net