Repository: dexidp/dex
Pull request #3764 (2024-09-24 04:46:34)

build(deps): bump cloud.google.com/go/compute/metadata from 0.5.0 to 0.5.2
Bumps [cloud.google.com/go/compute/metadata](https://github.com/googleapis/google-cloud-go) from 0.5.0 to 0.5.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/googleapis/google-cloud-go/releases">cloud.google.com/go/compute/metadata's releases</a>.</em></p>
<blockquote>
<h2>compute/metadata: v0.5.2</h2>
<h2><a href="https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.5.1...compute/metadata/v0.5.2">0.5.2</a> (2024-09-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>compute/metadata:</strong> Close Response Body for failed request (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10891">#10891</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/e91d45e4757a9e354114509ba9800085d9e0ff1f">e91d45e</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/googleapis/google-cloud-go/blob/main/CHANGES.md">cloud.google.com/go/compute/metadata's changelog</a>.</em></p>
<blockquote>
<h1>Changes</h1>
<h2><a href="https://github.com/googleapis/google-cloud-go/compare/v0.115.0...v0.115.1">0.115.1</a> (2024-08-13)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>cloud.google.com/go:</strong> Bump google.golang.org/grpc@v1.64.1 (<a href="https://github.com/googleapis/google-cloud-go/commit/8ecc4e9622e5bbe9b90384d5848ab816027226c5">8ecc4e9</a>)</li>
</ul>
<h2><a href="https://github.com/googleapis/google-cloud-go/compare/v0.114.0...v0.115.0">0.115.0</a> (2024-06-12)</h2>
<h3>Features</h3>
<ul>
<li><strong>internal/trace:</strong> Deprecate OpenCensus support (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10287">#10287</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/430ce8adea2d0be43461e2ca783b7c17794e983f">430ce8a</a>), refs <a href="https://redirect.github.com/googleapis/google-cloud-go/issues/2205">#2205</a> <a href="https://redirect.github.com/googleapis/google-cloud-go/issues/8655">#8655</a></li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>internal/postprocessor:</strong> Use approved image tag (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10341">#10341</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/a388fe5cf075d0af986861c70dcb7b9f97c31019">a388fe5</a>)</li>
</ul>
<h2><a href="https://github.com/googleapis/google-cloud-go/compare/v0.113.0...v0.114.0">0.114.0</a> (2024-05-23)</h2>
<h3>Features</h3>
<ul>
<li><strong>civil:</strong> Add Compare method to Date, Time, and DateTime (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10193">#10193</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/c2920d7c9007a11d9232c628fba5496197deeba4">c2920d7</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>internal/postprocessor:</strong> Add scopes to all appropriate commit lines (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10192">#10192</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/c21399bdc362c6c646c2c0f8c2c55903898e0eab">c21399b</a>)</li>
</ul>
<h2><a href="https://github.com/googleapis/google-cloud-go/compare/v0.112.2...v0.113.0">0.113.0</a> (2024-05-08)</h2>
<h3>Features</h3>
<ul>
<li><strong>civil:</strong> Add Compare method to Date, Time, and DateTime (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10010">#10010</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/34455c15d62b089f3281ff4c663245e72b257f37">34455c1</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>all:</strong> Bump x/net to v0.24.0 (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10000">#10000</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/ba31ed5fda2c9664f2e1cf972469295e63deb5b4">ba31ed5</a>)</li>
<li><strong>debugger:</strong> Add internaloption.WithDefaultEndpointTemplate (<a href="https://github.com/googleapis/google-cloud-go/commit/3b414084450a5764a0248756e95e13383a645f90">3b41408</a>)</li>
<li><strong>internal/aliasfix:</strong> Handle import paths correctly (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10097">#10097</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/fafaf0d0a293096559a4655ea61062cb896f1568">fafaf0d</a>)</li>
<li><strong>rpcreplay:</strong> Properly unmarshal dynamic message (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/9774">#9774</a>) (<a href="https://github.com/googleapis/google-cloud-go/commit/53ccb20d925ccb00f861958d9658b55738097dc6">53ccb20</a>), refs <a href="https://redirect.github.com/googleapis/google-cloud-go/issues/9773">#9773</a></li>
</ul>
<h3>Documentation</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/71a92e3330a500fb98e7149d6519b62be47be8b6"><code>71a92e3</code></a> chore(main): release auth 0.5.2 (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10422">#10422</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/32667635ca2efad05cd8c087c004ca07d7406913"><code>3266763</code></a> fix(auth): fetch initial token when CachedTokenProviderOptions.DisableAutoRef...</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/9eb4d2a1812bb1d6606bfe556f46001ea564366b"><code>9eb4d2a</code></a> chore(internal/protoveneer): generate support functions (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10416">#10416</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/eba87271bdad6bd44f1cda559a1bb4d6f73c5a94"><code>eba8727</code></a> doc(storage): add example for retention (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10413">#10413</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/1068ca892ed50589fd8d5dce78b75d1341f634fb"><code>1068ca8</code></a> testing(bigquery): fix legacy trace test (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10411">#10411</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/d6c543c3969016c63e158a862fc173dff60fb8d9"><code>d6c543c</code></a> chore: update gapic-generator-go to 0.44.0 (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10402">#10402</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/5cb0c26013eeb3bbe51174bee628a20c2ec775e0"><code>5cb0c26</code></a> feat(spanner): add support of checking row not found errors from ReadRow and ...</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/1d28ac6420855a623312f521e26e184c838bf671"><code>1d28ac6</code></a> chore(internal/protoveneer): doc for fields (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10352">#10352</a>)</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/2003148b71a734afd5c31a0106e5204414ece6e9"><code>2003148</code></a> feat(aiplatform): add enum value MALFORMED_FUNCTION_CALL to `.google.cloud.ai...</li>
<li><a href="https://github.com/googleapis/google-cloud-go/commit/ad8792d691c5eda8f65debddc2e0aa2e682c7bfe"><code>ad8792d</code></a> chore: release main (<a href="https://redirect.github.com/googleapis/google-cloud-go/issues/10399">#10399</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/googleapis/google-cloud-go/compare/v0.5.0...auth/v0.5.2">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cloud.google.com/go/compute/metadata&package-manager=go_modules&previous-version=0.5.0&new-version=0.5.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 63efdfdb..7b8f9b7e 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -17,5 +17,5 @@ contact_links:
     about: Please ask and answer questions here
 
   - name: 💡 Dex Enhancement Proposal
-    url: https://github.com/dexidp/dex/tree/master/enhancements/README.md
+    url: https://github.com/dexidp/dex/tree/master/docs/enhancements/README.md
     about: Open a proposal for significant architectural change
diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml
index 1235b06f..c6e131af 100644
--- a/.github/workflows/analysis-scorecard.yaml
+++ b/.github/workflows/analysis-scorecard.yaml
@@ -42,6 +42,6 @@ jobs:
           retention-days: 5
 
       - name: Upload results to GitHub Security tab
-        uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
+        uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
         with:
           sarif_file: results.sarif
diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml
index 606c655f..e4cb3be8 100644
--- a/.github/workflows/artifacts.yaml
+++ b/.github/workflows/artifacts.yaml
@@ -193,7 +193,7 @@ jobs:
         if: inputs.publish
 
       - name: Run Trivy vulnerability scanner
-        uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # 0.24.0
+        uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2 # 0.28.0
         with:
           input: image
           format: sarif
@@ -208,6 +208,6 @@ jobs:
           overwrite: true
 
       - name: Upload Trivy scan results to GitHub Security tab
-        uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
+        uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
         with:
           sarif_file: trivy-results.sarif
diff --git a/Dockerfile b/Dockerfile
index ba297ccd..a4039a18 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ ARG BASE_IMAGE=alpine
 
 FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.5.0@sha256:0c6a569797744e45955f39d4f7538ac344bfb7ebf0a54006a0a4297b153ccf0f AS xx
 
-FROM --platform=$BUILDPLATFORM golang:1.23.1-alpine3.20@sha256:ac67716dd016429be8d4c2c53a248d7bcdf06d34127d3dc451bda6aa5a87bc06 AS builder
+FROM --platform=$BUILDPLATFORM golang:1.23.2-alpine3.20@sha256:9dd2625a1ff2859b8d8b01d8f7822c0f528942fe56cfe7a1e7c38d3b8d72d679 AS builder
 
 COPY --from=xx / /
 
@@ -55,7 +55,7 @@ RUN wget -O /usr/local/bin/gomplate \
 
 # For Dependabot to detect base image versions
 FROM alpine:3.20.3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS alpine
-FROM gcr.io/distroless/static-debian12:nonroot@sha256:dcd3f1f09adef5689088c9c4d96a8d98c889d8281d3946145074f89eafe7e1af AS distroless
+FROM gcr.io/distroless/static-debian12:nonroot@sha256:26f9b99f2463f55f20db19feb4d96eb88b056e0f1be7016bb9296a464a89d772 AS distroless
 
 FROM $BASE_IMAGE
 
diff --git a/cmd/dex/config.go b/cmd/dex/config.go
index dd6d2e2a..aa49a181 100644
--- a/cmd/dex/config.go
+++ b/cmd/dex/config.go
@@ -284,6 +284,27 @@ func getORMBasedSQLStorage(normal, entBased StorageConfig) func() StorageConfig
 	}
 }
 
+// Recursively expand environment variables in the map to avoid
+// issues with JSON special characters and escapes
+func expandEnvInMap(m map[string]interface{}) {
+	for k, v := range m {
+		switch vt := v.(type) {
+		case string:
+			m[k] = os.ExpandEnv(vt)
+		case map[string]interface{}:
+			expandEnvInMap(vt)
+		case []interface{}:
+			for i, item := range vt {
+				if itemMap, ok := item.(map[string]interface{}); ok {
+					expandEnvInMap(itemMap)
+				} else if itemString, ok := item.(string); ok {
+					vt[i] = os.ExpandEnv(itemString)
+				}
+			}
+		}
+	}
+}
+
 var storages = map[string]func() StorageConfig{
 	"etcd":       func() StorageConfig { return new(etcd.Etcd) },
 	"kubernetes": func() StorageConfig { return new(kubernetes.Config) },
@@ -312,9 +333,24 @@ func (s *Storage) UnmarshalJSON(b []byte) error {
 	if len(store.Config) != 0 {
 		data := []byte(store.Config)
 		if featureflags.ExpandEnv.Enabled() {
-			// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
-			data = []byte(os.ExpandEnv(string(store.Config)))
+			var rawMap map[string]interface{}
+			if err := json.Unmarshal(store.Config, &rawMap); err != nil {
+				return fmt.Errorf("unmarshal config for env expansion: %v", err)
+			}
+
+			// Recursively expand environment variables in the map to avoid
+			// issues with JSON special characters and escapes
+			expandEnvInMap(rawMap)
+
+			// Marshal the expanded map back to JSON
+			expandedData, err := json.Marshal(rawMap)
+			if err != nil {
+				return fmt.Errorf("marshal expanded config: %v", err)
+			}
+
+			data = expandedData
 		}
+
 		if err := json.Unmarshal(data, storageConfig); err != nil {
 			return fmt.Errorf("parse storage config: %v", err)
 		}
@@ -358,13 +394,29 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
 	if len(conn.Config) != 0 {
 		data := []byte(conn.Config)
 		if featureflags.ExpandEnv.Enabled() {
-			// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
-			data = []byte(os.ExpandEnv(string(conn.Config)))
+			var rawMap map[string]interface{}
+			if err := json.Unmarshal(conn.Config, &rawMap); err != nil {
+				return fmt.Errorf("unmarshal config for env expansion: %v", err)
+			}
+
+			// Recursively expand environment variables in the map to avoid
+			// issues with JSON special characters and escapes
+			expandEnvInMap(rawMap)
+
+			// Marshal the expanded map back to JSON
+			expandedData, err := json.Marshal(rawMap)
+			if err != nil {
+				return fmt.Errorf("marshal expanded config: %v", err)
+			}
+
+			data = expandedData
 		}
+
 		if err := json.Unmarshal(data, connConfig); err != nil {
 			return fmt.Errorf("parse connector config: %v", err)
 		}
 	}
+
 	*c = Connector{
 		Type:   conn.Type,
 		Name:   conn.Name,
diff --git a/connector/saml/saml.go b/connector/saml/saml.go
index 1ab8e544..bc8ef726 100644
--- a/connector/saml/saml.go
+++ b/connector/saml/saml.go
@@ -597,6 +597,9 @@ func verifyResponseSig(validator *dsig.ValidationContext, data []byte) (signed [
 	}
 
 	response := doc.Root()
+	if response == nil {
+		return nil, false, fmt.Errorf("parse document: empty root")
+	}
 	transformedResponse, err := validator.Validate(response)
 	if err == nil {
 		// Root element is verified, return it.
@@ -609,7 +612,7 @@ func verifyResponseSig(validator *dsig.ValidationContext, data []byte) (signed [
 	//
 	// TODO: Only select from child elements of the root.
 	assertion, err := etreeutils.NSSelectOne(response, "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion")
-	if err != nil {
+	if err != nil || assertion == nil {
 		return nil, false, fmt.Errorf("response does not contain an Assertion element")
 	}
 	transformedAssertion, err := validator.Validate(assertion)
diff --git a/go.mod b/go.mod
index e2100a39..74a4d6e0 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/dexidp/dex
 go 1.21
 
 require (
-	cloud.google.com/go/compute/metadata v0.5.0
+	cloud.google.com/go/compute/metadata v0.5.2
 	entgo.io/ent v0.14.0
 	github.com/AppsFlyer/go-sundheit v0.6.0
 	github.com/Masterminds/semver v1.5.0
diff --git a/go.sum b/go.sum
index cbebe635..9f53f1b7 100644
--- a/go.sum
+++ b/go.sum
@@ -5,8 +5,8 @@ cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
 cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
 cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
 cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
-cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
-cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
+cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
+cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 entgo.io/ent v0.14.0 h1:EO3Z9aZ5bXJatJeGqu/EVdnNr6K4mRq3rWe5owt0MC4=
diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go
index 04837a7d..671e0e77 100644
--- a/pkg/httpclient/httpclient.go
+++ b/pkg/httpclient/httpclient.go
@@ -3,6 +3,7 @@ package httpclient
 import (
 	"crypto/tls"
 	"crypto/x509"
+	"encoding/base64"
 	"fmt"
 	"net"
 	"net/http"
@@ -10,6 +11,26 @@ import (
 	"time"
 )
 
+func extractCAs(input []string) [][]byte {
+	result := make([][]byte, 0, len(input))
+	for _, ca := range input {
+		if ca == "" {
+			continue
+		}
+
+		pemData, err := os.ReadFile(ca)
+		if err != nil {
+			pemData, err = base64.StdEncoding.DecodeString(ca)
+			if err != nil {
+				pemData = []byte(ca)
+			}
+		}
+
+		result = append(result, pemData)
+	}
+	return result
+}
+
 func NewHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) {
 	pool, err := x509.SystemCertPool()
 	if err != nil {
@@ -17,13 +38,11 @@ func NewHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, err
 	}
 
 	tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify}
-	for _, rootCA := range rootCAs {
-		rootCABytes, err := os.ReadFile(rootCA)
-		if err != nil {
-			return nil, fmt.Errorf("failed to read root-ca: %v", err)
-		}
+	for index, rootCABytes := range extractCAs(rootCAs) {
 		if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
-			return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
+			return nil, fmt.Errorf("rootCAs.%d is not in PEM format, certificate must be "+
+				"a PEM encoded string, a base64 encoded bytes that contain PEM encoded string, "+
+				"or a path to a PEM encoded certificate", index)
 		}
 	}
 
diff --git a/server/oauth2.go b/server/oauth2.go
index ec972bea..cc81a8a5 100644
--- a/server/oauth2.go
+++ b/server/oauth2.go
@@ -668,7 +668,8 @@ func validateRedirectURI(client storage.Client, redirectURI string) bool {
 		return true
 	}
 
-	// verify that the host is of form "http://localhost:(port)(path)" or "http://localhost(path)"
+	// verify that the host is of form "http://localhost:(port)(path)", "http://localhost(path)" or numeric form like
+	// "http://127.0.0.1:(port)(path)"
 	u, err := url.Parse(redirectURI)
 	if err != nil {
 		return false
@@ -676,11 +677,20 @@ func validateRedirectURI(client storage.Client, redirectURI string) bool {
 	if u.Scheme != "http" {
 		return false
 	}
-	if u.Host == "localhost" {
+	return isHostLocal(u.Host)
+}
+
+func isHostLocal(host string) bool {
+	if host == "localhost" || net.ParseIP(host).IsLoopback() {
 		return true
 	}
-	host, _, err := net.SplitHostPort(u.Host)
-	return err == nil && host == "localhost"
+
+	host, _, err := net.SplitHostPort(host)
+	if err != nil {
+		return false
+	}
+
+	return host == "localhost" || net.ParseIP(host).IsLoopback()
 }
 
 func validateConnectorID(connectors []storage.Connector, connectorID string) bool {
diff --git a/server/server.go b/server/server.go
index e299e32f..5c5faa30 100644
--- a/server/server.go
+++ b/server/server.go
@@ -443,6 +443,20 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
 		return nil, err
 	}
 	handleWithCORS("/.well-known/openid-configuration", discoveryHandler)
+	// Handle the root path for the better user experience.
+	handleWithCORS("/", func(w http.ResponseWriter, r *http.Request) {
+		_, err := fmt.Fprintf(w, `<!DOCTYPE html>
+			<title>Dex</title>
+			<h1>Dex IdP</h1>
+			<h3>A Federated OpenID Connect Provider</h3>
+			<p><a href=%q>Discovery</a></p>`,
+			s.issuerURL.String()+"/.well-known/openid-configuration")
+		if err != nil {
+			s.logger.Error("failed to write response", "err", err)
+			s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.")
+			return
+		}
+	})
 
 	// TODO(ericchiang): rate limit certain paths based on IP.
 	handleWithCORS("/token", s.handleToken)


Test patch:
diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go
index c6d37cb0..68abe1f7 100644
--- a/cmd/dex/config_test.go
+++ b/cmd/dex/config_test.go
@@ -273,7 +273,8 @@ func checkUnmarshalConfigWithEnv(t *testing.T, dexExpandEnv string, wantExpandEn
 	os.Setenv("DEX_FOO_USER_PASSWORD", "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy")
 	// For os.ExpandEnv ($VAR -> value_of_VAR):
 	os.Setenv("DEX_FOO_POSTGRES_HOST", "10.0.0.1")
-	os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", "bar")
+	os.Setenv("DEX_FOO_POSTGRES_PASSWORD", `psql"test\pass`)
+	os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", `abc"def\ghi`)
 	if dexExpandEnv != "UNSET" {
 		os.Setenv("DEX_EXPAND_ENV", dexExpandEnv)
 	} else {
@@ -288,6 +289,7 @@ storage:
     # Env variables are expanded in raw YAML source.
     # Single quotes work fine, as long as the env variable doesn't contain any.
     host: '$DEX_FOO_POSTGRES_HOST'
+    password: '$DEX_FOO_POSTGRES_PASSWORD'
     port: 65432
     maxOpenConns: 5
     maxIdleConns: 3
@@ -350,10 +352,12 @@ logger:
 
 	// This is not a valid hostname. It's only used to check whether os.ExpandEnv was applied or not.
 	wantPostgresHost := "$DEX_FOO_POSTGRES_HOST"
+	wantPostgresPassword := "$DEX_FOO_POSTGRES_PASSWORD"
 	wantOidcClientSecret := "$DEX_FOO_OIDC_CLIENT_SECRET"
 	if wantExpandEnv {
 		wantPostgresHost = "10.0.0.1"
-		wantOidcClientSecret = "bar"
+		wantPostgresPassword = `psql"test\pass`
+		wantOidcClientSecret = `abc"def\ghi`
 	}
 
 	want := Config{
@@ -363,6 +367,7 @@ logger:
 			Config: &sql.Postgres{
 				NetworkDB: sql.NetworkDB{
 					Host:              wantPostgresHost,
+					Password:          wantPostgresPassword,
 					Port:              65432,
 					MaxOpenConns:      5,
 					MaxIdleConns:      3,
diff --git a/pkg/httpclient/httpclient_test.go b/pkg/httpclient/httpclient_test.go
index 07baea04..6f561c10 100644
--- a/pkg/httpclient/httpclient_test.go
+++ b/pkg/httpclient/httpclient_test.go
@@ -2,10 +2,12 @@ package httpclient_test
 
 import (
 	"crypto/tls"
+	"encoding/base64"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -20,18 +22,31 @@ func TestRootCAs(t *testing.T) {
 	assert.Nil(t, err)
 	defer ts.Close()
 
-	rootCAs := []string{"testdata/rootCA.pem"}
-	testClient, err := httpclient.NewHTTPClient(rootCAs, false)
-	assert.Nil(t, err)
+	runTest := func(name string, certs []string) {
+		t.Run(name, func(t *testing.T) {
+			rootCAs := certs
+			testClient, err := httpclient.NewHTTPClient(rootCAs, false)
+			assert.Nil(t, err)
 
-	res, err := testClient.Get(ts.URL)
-	assert.Nil(t, err)
+			res, err := testClient.Get(ts.URL)
+			assert.Nil(t, err)
 
-	greeting, err := io.ReadAll(res.Body)
-	res.Body.Close()
-	assert.Nil(t, err)
+			greeting, err := io.ReadAll(res.Body)
+			res.Body.Close()
+			assert.Nil(t, err)
 
-	assert.Equal(t, "Hello, client", string(greeting))
+			assert.Equal(t, "Hello, client", string(greeting))
+		})
+	}
+
+	runTest("From file", []string{"testdata/rootCA.pem"})
+
+	content, err := os.ReadFile("testdata/rootCA.pem")
+	assert.NoError(t, err)
+	runTest("From string", []string{string(content)})
+
+	contentStr := base64.StdEncoding.EncodeToString(content)
+	runTest("From bytes", []string{contentStr})
 }
 
 func TestInsecureSkipVerify(t *testing.T) {
diff --git a/server/oauth2_test.go b/server/oauth2_test.go
index 5f5fc3b6..2b733df8 100644
--- a/server/oauth2_test.go
+++ b/server/oauth2_test.go
@@ -452,6 +452,27 @@ func TestValidRedirectURI(t *testing.T) {
 			redirectURI: "http://localhost",
 			wantValid:   true,
 		},
+		{
+			client: storage.Client{
+				Public: true,
+			},
+			redirectURI: "http://127.0.0.1:8080/",
+			wantValid:   true,
+		},
+		{
+			client: storage.Client{
+				Public: true,
+			},
+			redirectURI: "http://127.0.0.1:991/bar",
+			wantValid:   true,
+		},
+		{
+			client: storage.Client{
+				Public: true,
+			},
+			redirectURI: "http://127.0.0.1",
+			wantValid:   true,
+		},
 		// Both Public + RedirectURIs configured: Could e.g. be a PKCE-enabled web app.
 		{
 			client: storage.Client{