From 940b2bab20239e63c8a5ed642d83556c77081fa9 Mon Sep 17 00:00:00 2001 From: Michael Sverdlik <113865820+michael-strigo@users.noreply.github.com> Date: Mon, 4 Sep 2023 23:09:41 +1000 Subject: [PATCH] Initial commit --- .gitignore | 2 + .traefik.yml | 12 +++++ LICENSE | 19 ++++++++ README.md | 85 ++++++++++++++++++++++++++++++++++ go.mod | 3 ++ plugin.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 .gitignore create mode 100644 .traefik.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1850d01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +plugins-local/ +dev/ \ No newline at end of file diff --git a/.traefik.yml b/.traefik.yml new file mode 100644 index 0000000..cb708ca --- /dev/null +++ b/.traefik.yml @@ -0,0 +1,12 @@ +displayName: Cloudflare Zero Trust to Nomad Token +type: middleware +# iconPath: .assets/icon.png +# bannerPath: .assets/banner.png + +import: github.com/strigo/traefik-auth-middleware + +summary: Exchange Cloudflare's JWT to Nomad Token and injects it as header for seamless Nomad authentication + +testData: + authMethodName: "auth" + nomadEndpoint: "http://localhost:4646" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7b3eb7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Strigo Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..96fc62e --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Cloudflare Zero Trust to Nomad Token + +A [Traefik][1] middleware that enables seamless login to Nomad when +operated behind Cloudflare Zero Trust. + +The middleware utilizes Nomad's [JWT authentication][2] and Cloudflare's +[application tokens][3] to exchange a JWT token from Cloudflare into Nomad's ACL +token. After that, the token injected as header into every request. + +This results with a seamless login into Nomad UI (and API). + +## Setup + +The setup instructions covers basic setup scenario. It assumes that: + +* You have Cloudflare Zero Trust environment configured with Nomad being + accessible via Cloudflared and Traefik. +* Traefik is able to talk with Nomad's API +* You are running Nomad 1.5+ + +### Nomad + +In Nomad, add a new JWT auth method: + +```shell +echo ' +{ + "JWKSURL": "https://.cloudflareaccess.com/cdn-cgi/access/certs", + "BoundIssuer": ["https://.cloudflareaccess.com"], + "BoundAudiences": [""], + "SigningAlgs": ["RS256"] +}' | nomad acl auth-method create -name Cloudflare -token-locality global -type JWT -max-token-ttl 8h -config - +``` +Make sure to config the above to fit your setup. + +### Traefik + +First, add plugin configuration in the static config: + +```yml +experimental: + plugins: + cfauth: + moduleName: github.com/strigo/traefik-auth-middleware + version: v0.1.0 +``` + +Now add the middleware into your routing config. Here's one example: + +```yml +http: + middlewares: + auth: + plugin: + cfauth: + authMethodName: Cloudflare + nomadEndpoint: http://localhost:4646 + + services: + nomad: + loadBalancer: + servers: + - url: "http://localhost:4646/" + + routers: + nomad: + entrypoints: + - web + service: nomad + rule: "Host(`example.com`)" + middlewares: + - auth +``` + +## Questions & Issues + +Feel free to open an issue request. + +ʕ•ᴥ•ʔ + + + +[1]: https://traefik.io/traefik/ +[2]: https://developer.hashicorp.com/nomad/docs/commands/login +[3]: https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/application-token/ \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5dd401f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/strigo/traefik-auth-middleware + +go 1.20.0 diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..3496882 --- /dev/null +++ b/plugin.go @@ -0,0 +1,128 @@ +package traefik_auth_middleware + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "time" +) + +const ( + CF_HEADER = "Cf-Access-Jwt-Assertion" + NOMAD_HEADER = "X-Nomad-Token" +) + +var ( + Cache map[string]Token +) + +type Config struct { + NomadEndpoint string `json:"nomadEndpoint,omitempty"` + AuthMethodName string `json:"authMethodName,omitempty"` +} + +func CreateConfig() *Config { + return &Config{ + NomadEndpoint: "http://localhost:4646", + } +} + +type Plugin struct { + next http.Handler + name string + config *Config + client *http.Client + logger *log.Logger +} + +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + Cache = make(map[string]Token, 1024) + return &Plugin{ + next: next, + name: name, + config: config, + client: &http.Client{}, + logger: log.New(os.Stderr, fmt.Sprintf("[%v] " ,name), log.Ltime | log.Lmicroseconds), + }, nil +} + +// Handle HTTP request in the middleware chain +func (p *Plugin) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + cfjwt :=req.Header.Get(CF_HEADER) + if cfjwt == "" { + p.logger.Println("No Cf-Access-Jwt-Assertion header found") + p.next.ServeHTTP(rw, req) + return + } + + // Check if token already cached and valid. If not, reach out to Nomad to + // get a new one and cache it. + token, ok := Cache[cfjwt] + if !ok || time.Now().UTC().After(token.ExpirationTime) { + var err error + + p.logger.Println("Assertion not cached - connecting to Nomad") + token, err = p.login(cfjwt) + if err != nil { + // in case of error, proceed to next without doing anything + p.logger.Printf("Nomad error: %v\n", err) + p.next.ServeHTTP(rw, req) + return + } + + Cache[cfjwt] = token + } + + req.Header.Set(NOMAD_HEADER, token.SecretID) + + p.next.ServeHTTP(rw, req) +} + +type Token struct { + AccessorID string `json:"AccessorID"` + SecretID string `json:"SecretID"` + ExpirationTime time.Time `json:"ExpirationTime"` +} + +type LoginRequestBody struct { + AuthMethodName string + LoginToken string +} + +// Login to Nomad with jwt and return a Token +func (p *Plugin) login(jwt string) (Token, error) { + req_body, err := json.Marshal(LoginRequestBody{p.config.AuthMethodName, jwt}) + if err != nil { + return Token{}, err + } + + url, err := url.JoinPath(p.config.NomadEndpoint, "v1", "acl/login") + if err != nil { + return Token{}, err + } + + resp, err := p.client.Post(url, "application/json", bytes.NewReader(req_body)) + if err != nil { + return Token{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return Token{}, fmt.Errorf("unexpected return code (%v) from nomad", resp.StatusCode) + } + + resp_body, err := io.ReadAll(resp.Body) + if err != nil { + return Token{}, err + } + var token Token + json.Unmarshal(resp_body, &token) + + return token, nil +}