From da94d6b2b0398362e0c63a99a93839a572d58e55 Mon Sep 17 00:00:00 2001 From: Dennis Oderwald Date: Tue, 4 Nov 2025 17:01:47 +0100 Subject: [PATCH] Init --- Dockerfile | 20 +++++++++ README.md | 87 ++++++++++++++++++++++++++++++++++++++++ main.go | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..078c65f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +COPY . . + +RUN go mod init opencloud-oidc-webfinger-proxy || true +RUN go mod tidy +RUN go build -o opencloud-oidc-webfinger-proxy main.go + +FROM alpine:latest + +WORKDIR /app +COPY --from=builder /app/opencloud-oidc-webfinger-proxy . + +EXPOSE 9210 +ENV PORT=9210 +ENV REDIRECT_TEMPLATE="" + +CMD ["./opencloud-oidc-webfinger-proxy"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..10be7ff --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# opencloud-oidc-webfinger-proxy + +A lightweight **Go-based reverse proxy** that dynamically rewrites `href` fields in **WebFinger responses** for OpenID Connect (OIDC) clients. + +This service is primarily designed for **OpenCloud** environments but can be used with any OIDC implementation that requires dynamic issuer rewriting based on user agent or suffix mapping. + +--- + +## 🚀 Features + +- 🔄 Dynamically rewrites `href` values in WebFinger JSON responses +- ⚙️ Fully configurable via environment variables — no code changes required +- 🧩 Compatible with any OIDC-compliant identity provider (e.g. Authentik, Authelia, Dex, etc.) +- 🧠 Extremely lightweight and stateless — built with Go +- 🧱 Works seamlessly behind Nginx or any reverse proxy + +--- + +## 🧠 How it works + +1. The proxy listens for requests on `/.well-known/webfinger`. +2. It forwards the request to the configured **upstream WebFinger service**. +3. The JSON response is inspected and any `href` fields matching `HREF_PATTERN` are replaced with + `HREF_REPLACEMENT + + "/"`. +4. The **issuer suffix** is provided via the HTTP header `X-Issuer-Suffix`, + or falls back to the configured `DEFAULT_SUFFIX`. + +Example: + +# Upstream response +```json +{ + "href": "https://auth.domain.com/application/o/opencloud/" +} +``` + +# Modified by proxy (Header: X-Issuer-Suffix=opencloud-android) +```json +{ + "href": "https://auth.domain.com/application/o/opencloud-android/" +} +``` + +## ⚙️ Environment Variables + +| Variable | Description | Example | +|-----------|-------------|----------| +| `PORT` | Port on which the proxy listens | `9210` | +| `UPSTREAM_URL` | Base URL of the real WebFinger endpoint (including schema) | `https://cloud.domain.com` | +| `HREF_PATTERN` | The part of the `href` URL to be matched and replaced | `https://auth.domain.com/application/o/` | +| `HREF_REPLACEMENT` | The base part to replace the matched pattern with | `https://auth.domain.com/application/o/` | +| `DEFAULT_SUFFIX` | Default issuer suffix when none is provided via header | `opencloud` | + +All variables are **required** — the service will exit with an error if any are missing. + +## 🧩 Example usage + +### Run locally + +```bash +export PORT=9210 +export UPSTREAM_URL=https://cloud.domain.com +export HREF_PATTERN=https://auth.domain.com/application/o/ +export HREF_REPLACEMENT=https://auth.domain.com/application/o/ +export DEFAULT_SUFFIX=opencloud + +go run main.go +``` + +Then test it: + +```bash +curl -H "X-Issuer-Suffix: opencloud-android" "http://localhost:9210/.well-known/webfinger?rel=http%3A%2F%2Fopenid.net%2Fspecs%2Fconnect%2F1.0%2Fissuer&resource=https%3A%2F%2Fcloud.domain.com" +``` + +Response: +```json +{ + "links": [ + { + "rel": "http://openid.net/specs/connect/1.0/issuer", + "href": "https://auth.domain.com/application/o/opencloud-android/" + } + ], + "subject": "https://cloud.domain.com" +} +``` \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..f8ff3b8 --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" +) + +func replaceHref(v any, pattern, replacement, suffix string) any { + re := regexp.MustCompile(regexp.QuoteMeta(pattern) + `[^/]+/`) + + switch val := v.(type) { + case map[string]any: + for k, v2 := range val { + if k == "href" { + if s, ok := v2.(string); ok && strings.Contains(s, pattern) { + newHref := re.ReplaceAllString(s, replacement+suffix+"/") + val[k] = newHref + } + } else { + val[k] = replaceHref(v2, pattern, replacement, suffix) + } + } + case []any: + for i, v2 := range val { + val[i] = replaceHref(v2, pattern, replacement, suffix) + } + } + return v +} + +func handler(w http.ResponseWriter, r *http.Request) { + upstream := os.Getenv("UPSTREAM_URL") + pattern := os.Getenv("HREF_PATTERN") + replacement := os.Getenv("HREF_REPLACEMENT") + defaultSuffix := os.Getenv("DEFAULT_SUFFIX") + + for k, v := range map[string]string{ + "UPSTREAM_URL": upstream, + "HREF_PATTERN": pattern, + "HREF_REPLACEMENT": replacement, + "DEFAULT_SUFFIX": defaultSuffix, + } { + if v == "" { + errMsg := fmt.Sprintf("Missing required environment variable: %s", k) + log.Fatal("❌ " + errMsg) + } + } + + suffix := r.Header.Get("X-Issuer-Suffix") + if suffix == "" { + suffix = defaultSuffix + } + + target := upstream + r.URL.RequestURI() + log.Printf("→ Fetching %s (suffix=%s)", target, suffix) + + resp, err := http.Get(target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var data any + if err := json.Unmarshal(body, &data); err != nil { + for k, v := range resp.Header { + if len(v) > 0 { + w.Header().Set(k, v[0]) + } + } + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + data = replaceHref(data, pattern, replacement, suffix) + modified, _ := json.MarshalIndent(data, "", " ") + + for k, v := range resp.Header { + if strings.ToLower(k) == "content-length" { + continue + } + if len(v) > 0 { + w.Header().Set(k, v[0]) + } + } + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + w.WriteHeader(resp.StatusCode) + w.Write(modified) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + http.HandleFunc("/.well-known/webfinger", handler) + log.Printf("✅ Listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +}