diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..29502615 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,50 @@ +name: coverage + +on: + workflow_dispatch: + push: + pull_request: + +jobs: + coverage: + env: + GOPATH: ${{ github.workspace }} + VOUCH_ROOT: ${{ github.workspace }}/src/github.com/${{ github.repository }} + defaults: + run: + working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ['1.14', '1.15'] + # go: ['1.15'] + + steps: + - uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go }} + - name: checkout + uses: actions/checkout@v2 + with: + path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} + - name: goget + run: ./do.sh goget + - name: coverage test + run: ./do.sh coverage + + - name: Send coverage + uses: shogo82148/actions-goveralls@v1 + with: + path-to-profile: ${{ env.GOPATH }}/src/github.com/${{ github.repository }}/.cover/cover.out + flag-name: Go-${{ matrix.go }} + parallel: true + + # notifies that all test jobs are finished. + finish: + needs: coverage + runs-on: ubuntu-latest + steps: + - uses: shogo82148/actions-goveralls@v1 + with: + parallel-finished: true \ No newline at end of file diff --git a/coverage_report.sh b/coverage_report.sh deleted file mode 100755 index 1d64f1fe..00000000 --- a/coverage_report.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -# Generate test coverage statistics for Go packages. -# -# Works around the fact that `go test -coverprofile` currently does not work -# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 -# -# Usage: script/coverage [--html|--coveralls] -# -# --html Additionally create HTML report and open it in browser -# --coveralls Push coverage statistics to coveralls.io -# - -set -e - -workdir=.cover -profile="$workdir/cover.out" -mode=count - -generate_cover_data() { - rm -rf "$workdir" - mkdir "$workdir" - - for pkg in "$@"; do - f="$workdir/$(echo $pkg | tr / -).cover" - go test -covermode="$mode" -coverprofile="$f" "$pkg" - done - - echo "mode: $mode" >"$profile" - grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" -} - -show_cover_report() { - go tool cover -${1}="$profile" -} - -push_to_coveralls() { - echo "Pushing coverage statistics to coveralls.io" - goveralls -coverprofile="$profile" -} - -generate_cover_data $(go list ./...) -show_cover_report func -case "$1" in -"") - ;; ---html) - show_cover_report html ;; ---coveralls) - push_to_coveralls ;; -*) - echo >&2 "error: invalid option: $1"; exit 1 ;; -esac diff --git a/do.sh b/do.sh index 093dad2a..bb7f33df 100755 --- a/do.sh +++ b/do.sh @@ -148,9 +148,11 @@ _redact() { } coverage() { - export EXTRA_TEST_ARGS='-cover' - test - go tool cover -html=coverage.out -o coverage.html + mkdir -p .cover && go test -v -coverprofile=.cover/cover.out ./... +} + +coveragereport() { + go tool cover -html=.cover/cover.out -o .cover/coverage.html } test() { @@ -347,7 +349,8 @@ usage() { $0 drunalpine [args] - run docker container for alpine $0 test [./pkg_test.go] - run go tests (defaults to all tests) $0 test_logging - test the logging output - $0 coverage - coverage report + $0 coverage - coverage test + $0 coveragereport - coverage report published to .cover/coverage.html $0 profile - go pprof tools $0 bug_report domain.com - print config file removing secrets and each provided domain $0 gogo [gocmd] - run, build, any go cmd @@ -379,6 +382,7 @@ case "$ARG" in |'watch' \ |'gobuildstatic' \ |'coverage' \ + |'coveragereport' \ |'stats' \ |'usage' \ |'bug_report' \ diff --git a/handlers/auth.go b/handlers/auth.go index 3c2493b0..b277bdcb 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -97,7 +97,13 @@ func AuthStateHandler(w http.ResponseWriter, r *http.Request) { // SUCCESS!! they are authorized // issue the jwt - tokenstring := jwtmanager.CreateUserTokenString(user, customClaims, ptokens) + + tokenstring, err := jwtmanager.NewVPJWT(user, customClaims, ptokens) + if err != nil { + responses.Error500(w, r, fmt.Errorf("/auth Token creation failure: %w . Please seek support from your administrator", err)) + return + + } cookie.SetCookie(w, r, tokenstring) // get the originally requested URL so we can send them on their way @@ -156,10 +162,10 @@ func verifyUser(u interface{}) (bool, error) { // Domains case len(cfg.Cfg.Domains) != 0: if domains.IsUnderManagement(user.Email) { - log.Debugf("verifyUser: Success! Email %s found within a "+cfg.Branding.FullName+" managed domain", user.Email) + log.Debugf("verifyUser: Success! Email %s found within a %s managed domain", user.Email, cfg.Branding.FullName) return true, nil } - return false, fmt.Errorf("verifyUser: Email %s is not within a "+cfg.Branding.FullName+" managed domain", user.Email) + return false, fmt.Errorf("verifyUser: Email %s is not within a %s managed domain", user.Email, cfg.Branding.FullName) // nothing configured, allow everyone through default: diff --git a/handlers/handlers_test.go b/handlers/handlers_test.go index e3a7f126..5a118bb0 100644 --- a/handlers/handlers_test.go +++ b/handlers/handlers_test.go @@ -179,7 +179,6 @@ func init() { lc = jwtmanager.VouchClaims{ u1.Sub, u1.Username, - jwtmanager.Sites, customClaims.Claims, t1.PAccessToken, t1.PIdToken, @@ -201,7 +200,8 @@ func TestParsedIdPTokens(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setUp(tt.configFile) - uts := jwtmanager.CreateUserTokenString(u1, customClaims, t1) + uts, err := jwtmanager.NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) utsParsed, _ := jwtmanager.ParseTokenString(uts) utsPtokens, _ := jwtmanager.PTokenClaims(utsParsed) diff --git a/handlers/validate.go b/handlers/validate.go index a94d7dda..83bcebcf 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -51,7 +51,7 @@ func ValidateRequestHandler(w http.ResponseWriter, r *http.Request) { } if !cfg.Cfg.AllowAllUsers { - if !claims.SiteInClaims(r.Host) { + if !claims.SiteInAudience(r.Host) { send401or200PublicAccess(w, r, fmt.Errorf("http header 'Host: %s' not authorized for configured `vouch.domains` (is Host being sent properly?)", r.Host)) return diff --git a/handlers/validate_test.go b/handlers/validate_test.go index 9bf968c2..e3542988 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -37,7 +37,8 @@ func BenchmarkValidateRequestHandler(b *testing.B) { tokens := structs.PTokens{} customClaims := structs.CustomClaims{} - userTokenString := jwtmanager.CreateUserTokenString(*user, customClaims, tokens) + userTokenString, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(b, err) c := &http.Cookie{ // Name: cfg.Cfg.Cookie.Name + "_1of1", @@ -80,12 +81,13 @@ func TestValidateRequestHandlerPerf(t *testing.T) { tokens := structs.PTokens{} customClaims := structs.CustomClaims{} - userTokenString := jwtmanager.CreateUserTokenString(*user, customClaims, tokens) + vpjwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) c := &http.Cookie{ // Name: cfg.Cfg.Cookie.Name + "_1of1", Name: cfg.Cfg.Cookie.Name, - Value: userTokenString, + Value: vpjwt, Expires: time.Now().Add(1 * time.Hour), } @@ -169,7 +171,8 @@ func TestValidateRequestHandlerWithGroupClaims(t *testing.T) { Email: "test@example.com", Name: "Test Name", } - userTokenString := jwtmanager.CreateUserTokenString(*user, customClaims, tokens) + vpjwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) req, err := http.NewRequest("GET", "/validate", nil) if err != nil { @@ -179,7 +182,7 @@ func TestValidateRequestHandlerWithGroupClaims(t *testing.T) { req.AddCookie(&http.Cookie{ // Name: cfg.Cfg.Cookie.Name + "_1of1", Name: cfg.Cfg.Cookie.Name, - Value: userTokenString, + Value: vpjwt, Expires: time.Now().Add(1 * time.Hour), }) @@ -229,7 +232,8 @@ func TestJWTCacheHandler(t *testing.T) { tokens := structs.PTokens{} customClaims := structs.CustomClaims{} - jwt := jwtmanager.CreateUserTokenString(*user, customClaims, tokens) + jwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) badjwt := strings.ReplaceAll(jwt, "a", "z") badjwt = strings.ReplaceAll(badjwt, "b", "x") diff --git a/pkg/jwtmanager/jwtmanager.go b/pkg/jwtmanager/jwtmanager.go index f828b738..dda84a9b 100644 --- a/pkg/jwtmanager/jwtmanager.go +++ b/pkg/jwtmanager/jwtmanager.go @@ -29,13 +29,12 @@ import ( "github.com/vouch/vouch-proxy/pkg/structs" ) -// const numSites = 2 +const comma = "," // VouchClaims jwt Claims specific to vouch type VouchClaims struct { - Sub string `json:"sub"` - Username string `json:"username"` - Sites []string `json:"sites"` // tempting to make this a map but the array is fewer characters in the jwt + Sub string `json:"sub"` + Username string `json:"username"` CustomClaims map[string]interface{} PAccessToken string PIdToken string @@ -45,49 +44,53 @@ type VouchClaims struct { // StandardClaims jwt.StandardClaims implementation var StandardClaims jwt.StandardClaims -// CustomClaims implementation -// var CustomClaims map[string]interface{} - -// Sites added to VouchClaims -var Sites []string var logger *zap.Logger var log *zap.SugaredLogger +var aud string // Configure see main.go configure() func Configure() { log = cfg.Logging.Logger logger = cfg.Logging.FastLogger cacheConfigure() + aud = audience() StandardClaims = jwt.StandardClaims{ - Issuer: cfg.Cfg.JWT.Issuer, + Issuer: cfg.Cfg.JWT.Issuer, + Audience: aud, } - populateSites() } -func populateSites() { - Sites = make([]string, 0) +// `aud` of the issued JWT https://tools.ietf.org/html/rfc7519#section-4.1.3 +func audience() string { + aud := make([]string, 0) // TODO: the Sites that end up in the JWT come from here // if we add fine grain ability (ACL?) to the equation // then we're going to have to add something fancier here for i := 0; i < len(cfg.Cfg.Domains); i++ { - Sites = append(Sites, cfg.Cfg.Domains[i]) + aud = append(aud, cfg.Cfg.Domains[i]) + } + if cfg.Cfg.Cookie.Domain != "" { + aud = append(aud, cfg.Cfg.Cookie.Domain) } + return strings.Join(aud, comma) } -// CreateUserTokenString converts user to signed jwt -func CreateUserTokenString(u structs.User, customClaims structs.CustomClaims, ptokens structs.PTokens) string { +// NewVPJWT issue a signed Vouch Proxy JWT for a user +func NewVPJWT(u structs.User, customClaims structs.CustomClaims, ptokens structs.PTokens) (string, error) { // User`token` // u.PrepareUserData() claims := VouchClaims{ u.Sub, u.Username, - Sites, customClaims.Claims, ptokens.PAccessToken, ptokens.PIdToken, StandardClaims, } + claims.Audience = aud + claims.ExpiresAt = time.Now().Add(time.Minute * time.Duration(cfg.Cfg.JWT.MaxAge)).Unix() + // https://github.com/vouch/vouch-proxy/issues/287 if cfg.Cfg.Headers.AccessToken == "" { claims.PAccessToken = "" @@ -97,8 +100,6 @@ func CreateUserTokenString(u structs.User, customClaims structs.CustomClaims, pt claims.PIdToken = "" } - claims.StandardClaims.ExpiresAt = time.Now().Add(time.Minute * time.Duration(cfg.Cfg.JWT.MaxAge)).Unix() - // https://godoc.org/github.com/dgrijalva/jwt-go#NewWithClaims token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims) @@ -109,15 +110,15 @@ func CreateUserTokenString(u structs.User, customClaims structs.CustomClaims, pt ss, err := token.SignedString([]byte(cfg.Cfg.JWT.Secret)) // ss, err := token.SignedString([]byte("testing")) if ss == "" || err != nil { - log.Errorf("signed token error: %s", err) + return "", fmt.Errorf("New JWT: signed token error: %s", err) } if cfg.Cfg.JWT.Compress { ss, err = compressAndEncodeTokenString(ss) if ss == "" || err != nil { - log.Errorf("compressed token error: %s", err) + return "", fmt.Errorf("New JWT: compressed token error: %w", err) } } - return ss + return ss, nil } // TokenIsValid gett better error reporting @@ -143,11 +144,11 @@ func TokenIsValid(token *jwt.Token, err error) bool { func SiteInToken(site string, token *jwt.Token) bool { if claims, ok := token.Claims.(*VouchClaims); ok { log.Debugf("site %s claim %v", site, claims) - if claims.SiteInClaims(site) { + if claims.SiteInAudience(site) { return true } } - log.Errorf("site %s not found in token", site) + log.Errorf("site %s not found in token audience", site) return false } @@ -170,11 +171,11 @@ func ParseTokenString(tokenString string) (*jwt.Token, error) { } -// SiteInClaims does the claim contain the value? -func (claims *VouchClaims) SiteInClaims(site string) bool { - for _, s := range claims.Sites { +// SiteInAudience does the claim contain the value? +func (claims *VouchClaims) SiteInAudience(site string) bool { + for _, s := range strings.Split(aud, comma) { if strings.Contains(site, s) { - log.Debugf("site %s is found for claims.Site %s", site, s) + log.Debugf("site %s is found for claims.Audience %s", site, s) return true } } diff --git a/pkg/jwtmanager/jwtmanager_test.go b/pkg/jwtmanager/jwtmanager_test.go index d36959d1..49842b5e 100644 --- a/pkg/jwtmanager/jwtmanager_test.go +++ b/pkg/jwtmanager/jwtmanager_test.go @@ -52,7 +52,6 @@ func init() { lc = VouchClaims{ u1.Sub, u1.Username, - Sites, customClaims.Claims, t1.PAccessToken, t1.PIdToken, @@ -62,7 +61,7 @@ func init() { } func TestClaims(t *testing.T) { - populateSites() + aud = audience() log.Debugf("jwt config %s %d", string(cfg.Cfg.JWT.Secret), cfg.Cfg.JWT.MaxAge) assert.NotEmpty(t, cfg.Cfg.JWT.Secret) assert.NotEmpty(t, cfg.Cfg.JWT.MaxAge) @@ -72,9 +71,10 @@ func TestClaims(t *testing.T) { // log.Infof("lc d %s", d.String()) // lc.StandardClaims.ExpiresAt = now.Add(time.Duration(ExpiresAtMinutes) * time.Minute).Unix() // log.Infof("lc expiresAt %d", now.Unix()-lc.StandardClaims.ExpiresAt) - uts := CreateUserTokenString(u1, customClaims, t1) + uts, err := NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) utsParsed, _ := ParseTokenString(uts) log.Infof("utsParsed: %+v", utsParsed) - log.Infof("Sites: %+v", Sites) + log.Infof("Audience: %+v", aud) assert.True(t, SiteInToken(cfg.Cfg.Domains[0], utsParsed)) } diff --git a/pkg/responses/responses.go b/pkg/responses/responses.go index f3f6d6de..1319d1c7 100644 --- a/pkg/responses/responses.go +++ b/pkg/responses/responses.go @@ -114,6 +114,18 @@ func Error403(w http.ResponseWriter, r *http.Request, e error) { renderError(w, "403 Forbidden") } +// Error500 Internal Error +// something is not right, hopefully this never happens +func Error500(w http.ResponseWriter, r *http.Request, e error) { + log.Error(e) + log.Infof("If this error persists it may be worthy of a bug report but please check your setup first. See the README at %s", cfg.Branding.URL) + addErrandCancelRequest(r) + cookie.ClearCookie(w, r) + w.Header().Set(cfg.Cfg.Headers.Error, e.Error()) + w.WriteHeader(http.StatusInternalServerError) + renderError(w, "500 - Internal Server Error") +} + // cfg.ErrCtx is tested by `jwtmanager.JWTCacheHandler` func addErrandCancelRequest(r *http.Request) { ctx, cancel := context.WithCancel(r.Context())