Skip to content

Commit

Permalink
#373 set vouch.document_root for "vouch in a path"
Browse files Browse the repository at this point in the history
  • Loading branch information
bnfinet committed Aug 28, 2021
1 parent 4901f6c commit 428cda6
Show file tree
Hide file tree
Showing 14 changed files with 245 additions and 24 deletions.
1 change: 1 addition & 0 deletions .defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vouch:
testing: false
listen: 0.0.0.0
port: 9090
# document_root:
# domains:
allowAllUsers: false
publicAccess: false
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

Coming soon! Please document any work in progress here as part of your PR. It will be moved to the next tag when released.

## v0.33.0

- [Vouch Proxy running in a path](https://github.com/vouch/vouch-proxy/issues/373)

## v0.32.0

- [slack oidc example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_slack) and [slack app manifest](https://github.com/vouch/vouch-proxy/blob/master/examples/slack/vouch-slack-oidc-app-manifest.yml)
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,47 @@ server {
}
```

### Vouch Proxy "in a path"

As of `v0.33.0` Vouch Proxy can be served within an Nginx location (path) by configuring `vouch.document_root: /vp_in_a_path`

This avoids the need to setup a separate domain for Vouch Proxy such as `vouch.yourdomain.com`. For example VP login will be served from https://protectedapp.yourdomain.com/vp_in_a_path/login

```{.nginxconf}
server {
listen 443 ssl http2;
server_name protectedapp.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem;
# This location serves all Vouch Proxy endpoints as /vp_in_a_path/$uri
# including /vp_in_a_path/validate, /vp_in_a_path/login, /vp_in_a_path/logout, /vp_in_a_path/auth, /vp_in_a_path/auth/$STATE, etc
location /vp_in_a_path {
proxy_pass http://127.0.0.1:9090; # must not! have a slash at the end
proxy_set_header Host $http_host;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
# if /vp_in_a_path/validate returns `401 not authorized` then forward the request to the error401block
error_page 401 = @error401;
location @error401 {
# redirect to Vouch Proxy for login
return 302 https://protectedapp.yourdomain.com/vp_in_a_path/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount
}
location / {
auth_request /vp_in_a_path/validate;
proxy_pass http://127.0.0.1:8080;
# see the Nginx config above for additional headers which can be set from Vouch Proxy
}
```

### Additional Nginx Configurations

Additional Nginx configurations can be found in the [examples](https://github.com/vouch/vouch-proxy/tree/master/examples) directory.

## Configuring Vouch Proxy using Environmental Variables
Expand Down
3 changes: 3 additions & 0 deletions config/config.yml_example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ vouch:
listen: 0.0.0.0 # VOUCH_LISTEN
port: 9090 # VOUCH_PORT

# document_root - VOUCH_DOCUMENT_ROOT
# see README for "

# domains - VOUCH_DOMAINS
# each of these domains must serve the url https://vouch.$domains[0] https://vouch.$domains[1] ...
# so that the cookie which stores the JWT can be set in the relevant domain
Expand Down
17 changes: 17 additions & 0 deletions config/testing/handler_login_url_document_root.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
vouch:
document_root: /vouch_in_a_path
domains:
- example.com

cookie:
secure: false
domain: example.com

jwt:
secret: testingsecret

oauth:
provider: google
client_id: http://vouch.github.io
auth_url: https://indielogin.com/auth
callback_url: http://vouch.github.io:9090/auth
5 changes: 3 additions & 2 deletions handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ func CallbackHandler(w http.ResponseWriter, r *http.Request) {
responses.Error400(w, r, fmt.Errorf("/auth: could not find state in query %s", r.URL.RawQuery))
return
}

// has to have a trailing / in its path, because the path of the session cookie is set to /auth/{state}/.
authStateURL := fmt.Sprintf("/auth/%s/?%s", queryState, r.URL.RawQuery)
// see note in login.go and https://github.com/vouch/vouch-proxy/issues/373
authStateURL := fmt.Sprintf("%s/auth/%s/?%s", cfg.Cfg.DocumentRoot, queryState, r.URL.RawQuery)
responses.Redirect302(w, r, authStateURL)

}

// AuthStateHandler /auth/{state}/
Expand Down
95 changes: 95 additions & 0 deletions handlers/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
Copyright 2020 The Vouch Proxy Authors.
Use of this source code is governed by The MIT License (MIT) that
can be found in the LICENSE file. Software distributed under The
MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
OR CONDITIONS OF ANY KIND, either express or implied.
*/

package handlers

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/vouch/vouch-proxy/pkg/cfg"
)

func TestCallbackHandlerDocumentRoot(t *testing.T) {
handlerL := http.HandlerFunc(LoginHandler)
handlerA := http.HandlerFunc(CallbackHandler)

tests := []struct {
name string
configFile string
wantcode int
}{
{"should have URL that begins with DocumentRoot", "/config/testing/handler_login_url_document_root.yml", http.StatusFound},
{"should have URL that does not begin with DocumentRoot", "/config/testing/handler_login_url.yml", http.StatusFound},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setUp(tt.configFile)

// first make a request of /login to set the session cookie
reqLogin, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/login?url=http://myapp.example.com/logout", nil)
reqLogin.Header.Set("Host", "my.example.com")
if err != nil {
t.Fatal(err)
}
rrL := httptest.NewRecorder()
handlerL.ServeHTTP(rrL, reqLogin)

// grab the state from the session cookie to
session, err := sessstore.Get(reqLogin, cfg.Cfg.Session.Name)
state := session.Values["state"].(string)

// now mimic an IdP returning the state variable back to us
reqAuth, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/auth?state="+state, nil)
reqAuth.Header.Set("Host", "my.example.com")
if err != nil {
t.Fatal(err)
}
// transfer the cookie from rrL to reqAuth
rrA := httptest.NewRecorder()

handlerA.ServeHTTP(rrA, reqAuth)
if rrA.Code != tt.wantcode {
t.Errorf("LoginHandler() status = %v, want %v", rrA.Code, tt.wantcode)
}

// confirm the requst to $DocumentRoot/auth is redirected to $DocumentRoot/auth/$state
redirectURL, err := url.Parse(rrA.Header()["Location"][0])
if err != nil {
t.Fatal(err)
}
assert.Equal(t, fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state), redirectURL.Path)

})
}
}

func TestAuthStateHandler(t *testing.T) {
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
AuthStateHandler(tt.args.w, tt.args.r)
})
}
}
2 changes: 1 addition & 1 deletion handlers/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {

// set the path for the session cookie to only send the correct cookie to /auth/{state}/
// must have a trailing slash. Otherwise, it is send to all endpoints that _start_ with the cookie path.
session.Options.Path = fmt.Sprintf("/auth/%s/", state)
session.Options.Path = fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state)

log.Debugf("session state set to %s", session.Values["state"])

Expand Down
52 changes: 52 additions & 0 deletions handlers/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -128,6 +129,57 @@ func Test_getValidRequestedURL(t *testing.T) {
}
}

func TestLoginHandlerDocumentRoot(t *testing.T) {
handler := http.HandlerFunc(LoginHandler)

tests := []struct {
name string
configFile string
wantcode int
}{
{"general test", "/config/testing/handler_login_url_document_root.yml", http.StatusFound},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setUp(tt.configFile)

req, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/logout?url=http://myapp.example.com/login", nil)
req.Header.Set("Host", "my.example.com")
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != tt.wantcode {
t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode)
}

found := false
for _, c := range rr.Result().Cookies() {
if c.Name == cfg.Cfg.Session.Name {
if strings.HasPrefix(c.Path, cfg.Cfg.DocumentRoot+"/auth") {
found = true
}
}
}
if !found {
t.Errorf("session cookie is not set into path that begins with Cfg.DocumentRoot %s", cfg.Cfg.DocumentRoot)
}

// confirm the OAuthClient has a properly configured
redirectURL, err := url.Parse(rr.Header()["Location"][0])
if err != nil {
t.Fatal(err)
}
redirectParam := redirectURL.Query().Get("redirect_uri")
assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty")
assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth")

})
}
}
func TestLoginHandler(t *testing.T) {
handler := http.HandlerFunc(LoginHandler)

Expand Down
23 changes: 14 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,26 +147,31 @@ func main() {
"semver", semver,
"listen", scheme[tls]+"://"+listen,
"tls", tls,
"document_root", cfg.Cfg.DocumentRoot,
"oauth.provider", cfg.GenOAuth.Provider)

// router := mux.NewRouter()
router := httprouter.New()

if cfg.Cfg.DocumentRoot != "" {
logger.Debugf("adjusting all served URIs to be under %s", cfg.Cfg.DocumentRoot)
}

authH := http.HandlerFunc(handlers.ValidateRequestHandler)
router.HandlerFunc(http.MethodGet, "/validate", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH)))
router.HandlerFunc(http.MethodGet, "/_external-auth-:id", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH)))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/validate", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH)))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/_external-auth-:id", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH)))

loginH := http.HandlerFunc(handlers.LoginHandler)
router.HandlerFunc(http.MethodGet, "/login", timelog.TimeLog(loginH))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/login", timelog.TimeLog(loginH))

logoutH := http.HandlerFunc(handlers.LogoutHandler)
router.HandlerFunc(http.MethodGet, "/logout", timelog.TimeLog(logoutH))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/logout", timelog.TimeLog(logoutH))

callH := http.HandlerFunc(handlers.CallbackHandler)
router.HandlerFunc(http.MethodGet, "/auth/", timelog.TimeLog(callH))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth", timelog.TimeLog(callH))

authStateH := http.HandlerFunc(handlers.AuthStateHandler)
router.HandlerFunc(http.MethodGet, "/auth/:state/", timelog.TimeLog(authStateH))
router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth/:state/", timelog.TimeLog(authStateH))

healthH := http.HandlerFunc(handlers.HealthcheckHandler)
router.HandlerFunc(http.MethodGet, "/healthcheck", timelog.TimeLog(healthH))
Expand All @@ -175,9 +180,9 @@ func main() {
// router.ServeFiles("/static/*filepath", http.FS(staticFs))

// so instead we publish all three routes
router.Handler(http.MethodGet, "/static/css/main.css", http.FileServer(http.FS(staticFs)))
router.Handler(http.MethodGet, "/static/img/favicon.ico", http.FileServer(http.FS(staticFs)))
router.Handler(http.MethodGet, "/static/img/multicolor_V_500x500.png", http.FileServer(http.FS(staticFs)))
router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/css/main.css", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs))))
router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/favicon.ico", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs))))
router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/multicolor_V_500x500.png", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs))))

// this also works for static files
// router.NotFound = http.FileServer(http.FS(staticFs))
Expand Down
1 change: 1 addition & 0 deletions pkg/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Config struct {
LogLevel string `mapstructure:"logLevel"`
Listen string `mapstructure:"listen"`
Port int `mapstructure:"port"`
DocumentRoot string `mapstructure:"document_root" envconfig:"document_root"`
Domains []string `mapstructure:"domains"`
WhiteList []string `mapstructure:"whitelist"`
TeamWhiteList []string `mapstructure:"teamWhitelist"`
Expand Down
4 changes: 2 additions & 2 deletions pkg/cfg/cfg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func Test_configureFromEnvCfg(t *testing.T) {
senv := []string{"VOUCH_LISTEN", "VOUCH_JWT_ISSUER", "VOUCH_JWT_SECRET", "VOUCH_HEADERS_JWT",
"VOUCH_HEADERS_USER", "VOUCH_HEADERS_QUERYSTRING", "VOUCH_HEADERS_REDIRECT", "VOUCH_HEADERS_SUCCESS", "VOUCH_HEADERS_ERROR",
"VOUCH_HEADERS_CLAIMHEADER", "VOUCH_HEADERS_ACCESSTOKEN", "VOUCH_HEADERS_IDTOKEN", "VOUCH_COOKIE_NAME", "VOUCH_COOKIE_DOMAIN",
"VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY"}
"VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY", "VOUCH_DOCUMENT_ROOT"}
// array of strings
saenv := []string{"VOUCH_DOMAINS", "VOUCH_WHITELIST", "VOUCH_TEAMWHITELIST", "VOUCH_HEADERS_CLAIMS", "VOUCH_TESTURLS", "VOUCH_POST_LOGOUT_REDIRECT_URIS"}
// int
Expand Down Expand Up @@ -171,7 +171,7 @@ func Test_configureFromEnvCfg(t *testing.T) {
scfg := []string{Cfg.Listen, Cfg.JWT.Issuer, Cfg.JWT.Secret, Cfg.Headers.JWT,
Cfg.Headers.User, Cfg.Headers.QueryString, Cfg.Headers.Redirect, Cfg.Headers.Success, Cfg.Headers.Error,
Cfg.Headers.ClaimHeader, Cfg.Headers.AccessToken, Cfg.Headers.IDToken, Cfg.Cookie.Name, Cfg.Cookie.Domain,
Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key,
Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key, Cfg.DocumentRoot,
}

sacfg := [][]string{Cfg.Domains, Cfg.WhiteList, Cfg.TeamWhiteList, Cfg.Headers.Claims, Cfg.TestURLs, Cfg.LogoutRedirectURLs}
Expand Down
9 changes: 5 additions & 4 deletions pkg/responses/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import (

// Index variables passed to index.tmpl
type Index struct {
Msg string
TestURLs []string
Testing bool
Msg string
TestURLs []string
Testing bool
DocumentRoot string
}

var (
Expand All @@ -48,7 +49,7 @@ func Configure() {

// RenderIndex render the response as an HTML page, mostly used in testing
func RenderIndex(w http.ResponseWriter, msg string) {
if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing}); err != nil {
if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing, DocumentRoot: cfg.Cfg.DocumentRoot}); err != nil {
log.Error(err)
}
}
Expand Down
Loading

0 comments on commit 428cda6

Please sign in to comment.