Compare commits
1 commit
main
...
token-prox
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d787e134 |
5 changed files with 296 additions and 0 deletions
15
cmd/token-proxy/Dockerfile
Normal file
15
cmd/token-proxy/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM golang:1.24-alpine as builder
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
COPY main.go go.mod go.sum /src/
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN go build -o /token-proxy -ldflags '-extldflags "-static" -w -s' main.go
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=builder /token-proxy /token-proxy
|
||||||
|
|
||||||
|
ENTRYPOINT ["/token-proxy"]
|
||||||
8
cmd/token-proxy/example_args.yaml
Normal file
8
cmd/token-proxy/example_args.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
args:
|
||||||
|
- --upstream=http://incloud-web-nginx.incloud-web.svc:8080
|
||||||
|
- --http-address=0.0.0.0:8000
|
||||||
|
- --cookie-refresh=1h
|
||||||
|
- --cookie-name=kc-access
|
||||||
|
- --cookie-secure=true
|
||||||
|
- --cookie-secret=$(OAUTH2_PROXY_COOKIE_SECRET)
|
||||||
|
- --token-check-url=http://incloud-web-nginx.incloud-web.svc:8080/api/clusters/cozydev4/k8s/apis/core.cozystack.io/v1alpha1/tenantnamespaces
|
||||||
8
cmd/token-proxy/go.mod
Normal file
8
cmd/token-proxy/go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module token-proxy
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/gorilla/securecookie v1.1.2
|
||||||
|
)
|
||||||
6
cmd/token-proxy/go.sum
Normal file
6
cmd/token-proxy/go.sum
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
259
cmd/token-proxy/main.go
Normal file
259
cmd/token-proxy/main.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/gorilla/securecookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ----------------------------- flags ------------------------------------ */
|
||||||
|
|
||||||
|
var (
|
||||||
|
upstream, httpAddr, proxyPrefix string
|
||||||
|
cookieName, cookieSecretB64 string
|
||||||
|
cookieSecure bool
|
||||||
|
cookieRefresh time.Duration
|
||||||
|
tokenCheckURL string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.StringVar(&upstream, "upstream", "", "Upstream URL to proxy to (required)")
|
||||||
|
flag.StringVar(&httpAddr, "http-address", "0.0.0.0:8000", "Listen address")
|
||||||
|
flag.StringVar(&proxyPrefix, "proxy-prefix", "/oauth2", "URL prefix for control endpoints")
|
||||||
|
|
||||||
|
flag.StringVar(&cookieName, "cookie-name", "_oauth2_proxy_0", "Cookie name")
|
||||||
|
flag.StringVar(&cookieSecretB64, "cookie-secret", "", "Base64-encoded cookie secret")
|
||||||
|
flag.BoolVar(&cookieSecure, "cookie-secure", false, "Set Secure flag on cookie")
|
||||||
|
flag.DurationVar(&cookieRefresh, "cookie-refresh", 0, "Cookie refresh interval (e.g. 1h)")
|
||||||
|
flag.StringVar(&tokenCheckURL, "token-check-url", "", "URL for external token validation")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- templates -------------------------------- */
|
||||||
|
|
||||||
|
var loginTmpl = template.Must(template.New("login").Parse(`
|
||||||
|
<!doctype html><html><head><title>Login</title></head>
|
||||||
|
<body>
|
||||||
|
<h2>Enter ServiceAccount / OIDC token</h2>
|
||||||
|
{{if .Err}}<p style="color:red">{{.Err}}</p>{{end}}
|
||||||
|
<form method="POST" action="{{.Action}}">
|
||||||
|
<input style="width:420px" name="token" placeholder="Paste token" autofocus/>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body></html>`))
|
||||||
|
|
||||||
|
/* ----------------------------- helpers ---------------------------------- */
|
||||||
|
|
||||||
|
func decodeJWT(raw string) jwt.MapClaims {
|
||||||
|
tkn, _ := jwt.Parse(raw, nil)
|
||||||
|
if c, ok := tkn.Claims.(jwt.MapClaims); ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return jwt.MapClaims{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalTokenCheck(raw string) error {
|
||||||
|
if tokenCheckURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, tokenCheckURL, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+raw)
|
||||||
|
cli := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSession(sc *securecookie.SecureCookie, token string, exp, issued int64) (string, error) {
|
||||||
|
v := map[string]interface{}{
|
||||||
|
"access_token": token,
|
||||||
|
"expires": exp,
|
||||||
|
"issued": issued,
|
||||||
|
}
|
||||||
|
return sc.Encode(cookieName, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- main ------------------------------------- */
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
if upstream == "" {
|
||||||
|
log.Fatal("--upstream is required")
|
||||||
|
}
|
||||||
|
upURL, err := url.Parse(upstream)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid upstream url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cookieSecretB64 == "" {
|
||||||
|
cookieSecretB64 = os.Getenv("COOKIE_SECRET")
|
||||||
|
}
|
||||||
|
if cookieSecretB64 == "" {
|
||||||
|
log.Fatal("--cookie-secret or $COOKIE_SECRET is required")
|
||||||
|
}
|
||||||
|
secret, err := base64.StdEncoding.DecodeString(cookieSecretB64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("cookie-secret: %v", err)
|
||||||
|
}
|
||||||
|
sc := securecookie.New(secret, nil)
|
||||||
|
|
||||||
|
// control paths
|
||||||
|
signIn := path.Join(proxyPrefix, "sign_in")
|
||||||
|
signOut := path.Join(proxyPrefix, "sign_out")
|
||||||
|
userInfo := path.Join(proxyPrefix, "userinfo")
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(upURL)
|
||||||
|
|
||||||
|
/* ------------------------- /sign_in ---------------------------------- */
|
||||||
|
|
||||||
|
http.HandleFunc(signIn, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
_ = loginTmpl.Execute(w, struct {
|
||||||
|
Action string
|
||||||
|
Err string
|
||||||
|
}{Action: signIn})
|
||||||
|
case http.MethodPost:
|
||||||
|
token := strings.TrimSpace(r.FormValue("token"))
|
||||||
|
if token == "" {
|
||||||
|
_ = loginTmpl.Execute(w, struct {
|
||||||
|
Action string
|
||||||
|
Err string
|
||||||
|
}{Action: signIn, Err: "Token required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := externalTokenCheck(token); err != nil {
|
||||||
|
_ = loginTmpl.Execute(w, struct {
|
||||||
|
Action string
|
||||||
|
Err string
|
||||||
|
}{Action: signIn, Err: "Invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exp := time.Now().Add(24 * time.Hour).Unix()
|
||||||
|
claims := decodeJWT(token)
|
||||||
|
if v, ok := claims["exp"].(float64); ok {
|
||||||
|
exp = int64(v)
|
||||||
|
}
|
||||||
|
session, _ := encodeSession(sc, token, exp, time.Now().Unix())
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: session,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(exp, 0),
|
||||||
|
Secure: cookieSecure,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ------------------------- /sign_out --------------------------------- */
|
||||||
|
|
||||||
|
http.HandleFunc(signOut, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
Secure: cookieSecure,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, signIn, http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ------------------------- /userinfo --------------------------------- */
|
||||||
|
|
||||||
|
http.HandleFunc(userInfo, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sess map[string]interface{}
|
||||||
|
if err := sc.Decode(cookieName, c.Value, &sess); err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _ := sess["access_token"].(string)
|
||||||
|
claims := decodeJWT(token)
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"sub": claims["sub"],
|
||||||
|
"email": claims["email"],
|
||||||
|
"preferred_username": claims["preferred_username"],
|
||||||
|
"groups": claims["groups"],
|
||||||
|
"expires": sess["expires"],
|
||||||
|
"issued": sess["issued"],
|
||||||
|
"cookie_refresh_enable": cookieRefresh > 0,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ----------------------------- proxy --------------------------------- */
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, signIn, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sess map[string]interface{}
|
||||||
|
if err := sc.Decode(cookieName, c.Value, &sess); err != nil {
|
||||||
|
http.Redirect(w, r, signIn, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _ := sess["access_token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
http.Redirect(w, r, signIn, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookie refresh
|
||||||
|
if cookieRefresh > 0 {
|
||||||
|
if issued, ok := sess["issued"].(float64); ok {
|
||||||
|
if time.Since(time.Unix(int64(issued), 0)) > cookieRefresh {
|
||||||
|
enc, _ := encodeSession(sc, token, int64(sess["expires"].(float64)), time.Now().Unix())
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: enc,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(int64(sess["expires"].(float64)), 0),
|
||||||
|
Secure: cookieSecure,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("Listening on %s → %s (control prefix %s)", httpAddr, upURL, proxyPrefix)
|
||||||
|
if err := http.ListenAndServe(httpAddr, nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue