auth-proxy: proxies only authenticated requests to upstream, redirects to login page otherwise (#103)

* auth-proxy: inspects authenticated user

* ingress: chart and use in rpuppy

* auth-proxy: make it optional in rpuppy

* kratos: whitelist env pub/priv domains for auth return_to addr

* url-shortener: put behind auth-proxy

* pihole: replace oauth2-client with auth-proxy

* auth-proxy: fix upstream uri generation

* pihole: remove old chart using oauth2

* auth-proxy: remove temporary values file

* url-shortener: check x-user header for authentication

* auth: fix allowed_return_urls list

* auth-proxy: fix current address generation logic

---------

Co-authored-by: Giorgi Lekveishvili <lekva@gl-mbp-m1-max.local>
diff --git a/core/auth/proxy/Dockerfile b/core/auth/proxy/Dockerfile
new file mode 100644
index 0000000..6e8d93c
--- /dev/null
+++ b/core/auth/proxy/Dockerfile
@@ -0,0 +1,5 @@
+FROM gcr.io/distroless/static:nonroot
+
+ARG TARGETARCH
+
+COPY server_${TARGETARCH} /usr/bin/server
diff --git a/core/auth/proxy/Makefile b/core/auth/proxy/Makefile
new file mode 100644
index 0000000..053ab05
--- /dev/null
+++ b/core/auth/proxy/Makefile
@@ -0,0 +1,39 @@
+repo_name ?= giolekva
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+endif
+
+clean:
+	rm -f server server_*
+
+build: clean
+	go build -o server *.go
+
+build_arm64: export CGO_ENABLED=0
+build_arm64: export GO111MODULE=on
+build_arm64: export GOOS=linux
+build_arm64: export GOARCH=arm64
+build_arm64:
+	go build -o server_arm64 *.go
+
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+	go build -o server_amd64 *.go
+
+push_arm64: clean build_arm64
+	$(podman) build --platform linux/arm64 --tag=$(repo_name)/auth-proxy:arm64 .
+	$(podman) push $(repo_name)/auth-proxy:arm64
+
+push_amd64: clean build_amd64
+	$(podman) build --platform linux/amd64 --tag=$(repo_name)/auth-proxy:amd64 .
+	$(podman) push $(repo_name)/auth-proxy:amd64
+
+
+push: push_arm64 push_amd64
+	$(podman) manifest create $(repo_name)/auth-proxy:latest $(repo_name)/auth-proxy:arm64 $(repo_name)/auth-proxy:amd64
+	$(podman) manifest push $(repo_name)/auth-proxy:latest $(manifest_dest)
+	$(podman) manifest rm $(repo_name)/auth-proxy:latest
diff --git a/core/auth/proxy/main.go b/core/auth/proxy/main.go
new file mode 100644
index 0000000..8b3d837
--- /dev/null
+++ b/core/auth/proxy/main.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strings"
+)
+
+var port = flag.Int("port", 3000, "Port to listen on")
+var whoAmIAddr = flag.String("whoami-addr", "", "Kratos whoami endpoint address")
+var loginAddr = flag.String("login-addr", "", "Login page address")
+var upstream = flag.String("upstream", "", "Upstream service address")
+
+type user struct {
+	Identity struct {
+		Traits struct {
+			Username string `json:"username"`
+		} `json:"traits"`
+	} `json:"identity"`
+}
+
+type authError struct {
+	Error struct {
+		Status string `json:"status"`
+	} `json:"error"`
+}
+
+func getAddr(r *http.Request) (*url.URL, error) {
+	return url.Parse(fmt.Sprintf(
+		"%s://%s%s",
+		r.Header["X-Forwarded-Scheme"][0],
+		r.Header["X-Forwarded-Host"][0],
+		r.URL.RequestURI()))
+}
+
+func handle(w http.ResponseWriter, r *http.Request) {
+	user, err := queryWhoAmI(r.Cookies())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		if r.Method != http.MethodGet {
+			http.Error(w, "Unauthorized", http.StatusUnauthorized)
+			return
+		}
+		curr, err := getAddr(r)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		addr := fmt.Sprintf("%s?return_to=%s", *loginAddr, curr.String())
+		http.Redirect(w, r, addr, http.StatusSeeOther)
+		return
+	}
+	rc := r.Clone(context.Background())
+	rc.Header.Add("X-User", user.Identity.Traits.Username)
+	ru, err := url.Parse(fmt.Sprintf("http://%s%s", *upstream, r.URL.RequestURI()))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	rc.URL = ru
+	rc.RequestURI = ""
+	client := &http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		},
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := client.Do(rc)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for name, values := range resp.Header {
+		for _, value := range values {
+			w.Header().Add(name, value)
+		}
+	}
+	w.WriteHeader(resp.StatusCode)
+	if _, err := io.Copy(w, resp.Body); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func queryWhoAmI(cookies []*http.Cookie) (*user, error) {
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		return nil, err
+	}
+	client := &http.Client{
+		Jar: jar,
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		},
+	}
+	addr, err := url.Parse(*whoAmIAddr)
+	if err != nil {
+		return nil, err
+	}
+	client.Jar.SetCookies(addr, cookies)
+	resp, err := client.Get(*whoAmIAddr)
+	if err != nil {
+		return nil, err
+	}
+	data := make(map[string]any)
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return nil, err
+	}
+	// TODO(gio): remove debugging
+	b, err := json.MarshalIndent(data, "", "  ")
+	if err != nil {
+		return nil, err
+	}
+	fmt.Println(string(b))
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(data); err != nil {
+		return nil, err
+	}
+	tmp := buf.String()
+	if resp.StatusCode == http.StatusOK {
+		u := &user{}
+		if err := json.NewDecoder(strings.NewReader(tmp)).Decode(u); err != nil {
+			return nil, err
+		}
+		return u, nil
+	}
+	e := &authError{}
+	if err := json.NewDecoder(strings.NewReader(tmp)).Decode(e); err != nil {
+		return nil, err
+	}
+	if e.Error.Status == "Unauthorized" {
+		return nil, nil
+	}
+	return nil, fmt.Errorf("Unknown error: %s", tmp)
+}
+
+func main() {
+	flag.Parse()
+	http.HandleFunc("/", handle)
+	fmt.Printf("Starting HTTP server on port: %d\n", *port)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}