Skip to content

Commit

Permalink
feat: support authorization
Browse files Browse the repository at this point in the history
Zitadel authorization has been implemented. A new middleware uses
Zitadel's API to introspect a given token and verify it is valid. This
prevents unauthorized users from accesing the API.

Closes #17.
  • Loading branch information
crazybolillo committed Aug 31, 2024
1 parent 2cf6f93 commit 987a3f8
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

16 changes: 14 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand All @@ -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())
}
Expand Down
130 changes: 130 additions & 0 deletions internal/zitadel/zitadel.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 987a3f8

Please sign in to comment.