diff --git a/README.md b/README.md index 6c95892..2ba2d9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # eryth API to perform configuration management on Asterisk servers. Project named after [Erythrina americana](https://mexico.inaturalist.org/taxa/201455-Erythrina-americana). + +## Configuration + diff --git a/cmd/main.go b/cmd/main.go index 5be8fa2..59a7f8d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/crazybolillo/eryth/internal/bouncer" "github.com/crazybolillo/eryth/internal/handler" + "github.com/crazybolillo/eryth/internal/zitadel" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -70,8 +71,15 @@ func serve(ctx context.Context) error { r.Use(cors.Handler(cors.Options{ AllowedOrigins: strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ","), AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization"}, })) r.Use(middleware.AllowContentEncoding("application/json")) + r.Use(zitadel.Middleware(zitadel.Options{ + Host: os.Getenv("ZITADEL_HOST"), + ProjectID: os.Getenv("ZITADEL_PROJECT_ID"), + ClientID: os.Getenv("ZITADEL_CLIENT_ID"), + ClientSecret: os.Getenv("ZITADEL_CLIENT_SECRET"), + })) endpoint := handler.Endpoint{Conn: conn} r.Mount("/endpoints", endpoint.Router()) @@ -80,8 +88,12 @@ func serve(ctx context.Context) error { authorization := handler.Authorization{Bouncer: checker} r.Mount("/bouncer", authorization.Router()) - slog.Info("Listening on :8080") - err = http.ListenAndServe(":8080", r) + listen := os.Getenv("LISTEN_ADDR") + if listen == "" { + listen = ":8080" + } + slog.Info("Starting server", slog.String("bind", listen)) + err = http.ListenAndServe(listen, r) if err != nil { slog.Error("Failed to start server", "reason", err.Error()) } diff --git a/internal/zitadel/zitadel.go b/internal/zitadel/zitadel.go new file mode 100644 index 0000000..efa195b --- /dev/null +++ b/internal/zitadel/zitadel.go @@ -0,0 +1,130 @@ +package zitadel + +import ( + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +type Options struct { + Host string + ProjectID string + ClientID string + ClientSecret string +} + +type tokenInfo struct { + Active bool `json:"active"` + Audience []string `json:"aud,omitempty"` + ClientID string `json:"client_id,omitempty"` + Expires int64 `json:"exp,omitempty"` + Issuer string `json:"iss,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + ID string `json:"jti,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Scope string `json:"scope,omitempty"` + Type string `json:"token_type,omitempty"` + Username string `json:"username,omitempty"` +} + +type zitadel struct { + opts Options + client *http.Client +} + +func (z *zitadel) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + info, err := z.introspectToken(token) + if err != nil { + slog.Error("Failed to introspect ZITADEL token", slog.String("reason", err.Error())) + w.WriteHeader(http.StatusUnauthorized) + return + } + + err = z.validateToken(info) + if err != nil { + slog.Info("Rejected token", slog.String("reason", err.Error())) + w.WriteHeader(http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (z *zitadel) validateToken(info tokenInfo) error { + if !info.Active { + return errors.New("token is inactive") + } + + properAudience := false + for _, aud := range info.Audience { + if aud == z.opts.ProjectID { + properAudience = true + break + } + } + if !properAudience { + return errors.New("token is missing proper audience") + } + + if z.opts.Host != info.Issuer { + return errors.New("token issuer does not match zitadel host") + } + + return nil +} + +func (z *zitadel) introspectToken(token string) (tokenInfo, error) { + form := url.Values{} + form.Add("token", token) + + path, err := url.JoinPath(z.opts.Host, "/oauth/v2/introspect") + if err != nil { + return tokenInfo{}, err + } + + req, err := http.NewRequest("POST", path, strings.NewReader(form.Encode())) + if err != nil { + return tokenInfo{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set( + "Authorization", + "Basic "+base64.StdEncoding.EncodeToString([]byte(z.opts.ClientID+":"+z.opts.ClientSecret)), + ) + + res, err := z.client.Do(req) + if err != nil { + return tokenInfo{}, err + } + defer res.Body.Close() + + var info tokenInfo + decoder := json.NewDecoder(res.Body) + err = decoder.Decode(&info) + if err != nil { + return tokenInfo{}, err + } + + return info, nil +} + +func Middleware(opts Options) func(http.Handler) http.Handler { + z := &zitadel{ + opts: opts, + client: &http.Client{Timeout: 10 * time.Second}, + } + + return z.handler +}