Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added RS256 alg support via Okta lib #24

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
my.env
gorm.db
vendor/
git-gateway
Expand Down
10 changes: 9 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ addons:
- mongodb-org-server

go:
- 1.8
- 1.11

env:
- git-gateway_MONGODB_TEST_CONN_URL=127.0.0.1

before_install:
# git-gateway consists of multiple Go packages, which refer to each other by
# their absolute GitHub path, e.g. github.com/netlify/git-gateway/api
# That means, by default, if someone forks the repo, Travis won't pass for the
# branch on their own repo. To fix that, we move the directory
- mkdir -p $TRAVIS_BUILD_DIR $GOPATH/src/github.com/netlify
- test ! -d $GOPATH/src/github.com/netlify/git-gateway && mv $TRAVIS_BUILD_DIR $GOPATH/src/github.com/netlify/git-gateway || true

install: make deps
script: make all

Expand Down
10 changes: 8 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
FROM netlify/go-glide:v0.12.3
FROM golang:1.11.2-alpine3.8

ADD . /go/src/github.com/netlify/git-gateway

RUN useradd -m netlify && cd /go/src/github.com/netlify/git-gateway && make deps build && mv git-gateway /usr/local/bin/
RUN apk add --update alpine-sdk

RUN rm -rf /var/cache/apk/*

RUN go get -u github.com/Masterminds/glide golang.org/x/lint/golint

RUN adduser -D -u 1000 netlify && cd /go/src/github.com/netlify/git-gateway && make deps build lint && mv git-gateway /usr/local/bin/

USER netlify
CMD ["git-gateway"]
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ However, for most use cases you won’t want to require all content editors to h

Netlify’s Git Gateway lets you setup a gateway to your choice of Git provider's API ( now available with both GitHub and GitLab 🎉 ) that lets tools like Netlify CMS work with content, branches and pull requests on your users’ behalf.

The Git Gateway works with any identity service that can issue JWTs and only allows access when a JSON Web Token with sufficient permissions is present.
The Git Gateway works with some supported identity service that can issue JWTs and only allows access when a JSON Web Token with sufficient permissions is present.

To configure the gateway, see our `example.env` file

Expand All @@ -29,3 +29,39 @@ for GitLab:
/repos/:owner/:name/commits/
/repos/:owner/:name/tree/
```

### Trying out `git-gateway`

The instructions below is a way of testing out `git-gateway`. It assumes you have Docker installed and are familiar with Okta (an IDaaS). If you are using a different stack, please adjust the steps accordingly.

1. pull down this project
2. generate a `personal access token` on github. (recommended: using a test account and w/ `repo:status` and `public_repo` permission only)
https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/
3. `cp example.env my.env`
4. update `GITGATEWAY_GITHUB_ACCESS_TOKEN` value in `my.env` accordingly
5. update `GITGATEWAY_GITHUB_REPO` value in `my.env` (it will be where the content being stored, eg, `owner/netlify-cms-storage`.)
6. sign up for a Dev account on Okta: https://developer.okta.com/signup/
7. create a SPA Application onto the Dev account:
a. fill out the details
b. pick `Either Okta or App`
c. pick `Send ID Token directly to app (Okta Simplified)``
d. have redirect uri points to the url of your `my-netlify-cms` ip:port
(eg, `http://localhost:8080/admin` etc, see, https://github.com/<< your org >>/my-netlify-cms)
e. make sure `Authorization Servers` is activated
f. go to `Trusted Origins` tab and add the url for your `my-netlify-cms` instance
g. add yourself or a test user
8. update `ISSUER` value in `my.env` accordingly (eg, `https://dev-1234.oktapreview.com/oauth2/default`)
9. update `CLIENT_ID` value in `my.env` accordingly (eg, `32q897q234q324rq42322q`)
10. comment out `GITGATEWAY_ROLES` to disable role checking (authorization is controlled by `Assignments` on Okta)
11. update `GITGATEWAY_API_HOST` to `0.0.0.0`
12. inspect Dockerfile and then build the docker with this command:
`docker build -t netlify/git-gateway:latest .`
13. run `git-gateway` with this command:
`docker run --rm --env-file my.env -p 127.0.0.1:9999:9999 --expose 9999 -ti --name netlify-git-gateway "netlify/git-gateway:latest"`
14. update `config.yml` in your my-netlify-cms repo.
change `backend.name` value to `git-gateway`
change `backend.gateway_url` value to `http://localhost:9999`
15. integrate okta sign-in to your `my-netlify-cms` (eg, https://developer.okta.com/quickstart/#/widget/nodejs/express)
16. start your `my-netlify-cms` instance

See, Wiki page for additional information.
12 changes: 7 additions & 5 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var bearerRegexp = regexp.MustCompile(`^(?:B|b)earer (\S+$)`)
type API struct {
handler http.Handler
db storage.Connection
auth Auth
config *conf.GlobalConfiguration
version string
}
Expand Down Expand Up @@ -58,7 +59,8 @@ func NewAPI(globalConfig *conf.GlobalConfiguration, db storage.Connection) *API

// NewAPIWithVersion creates a new REST API using the specified version
func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfiguration, db storage.Connection, version string) *API {
api := &API{config: globalConfig, db: db, version: version}
auth := NewAuthWithVersion(ctx, version)
api := &API{config: globalConfig, db: db, auth: *auth, version: version}

xffmw, _ := xff.Default()

Expand All @@ -75,10 +77,10 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
r.Use(api.loadJWSSignatureHeader)
r.Use(api.loadInstanceConfig)
}
r.With(api.requireAuthentication).Mount("/github", NewGitHubGateway())
r.With(api.requireAuthentication).Mount("/gitlab", NewGitLabGateway())
r.With(api.requireAuthentication).Mount("/bitbucket", NewBitBucketGateway())
r.With(api.requireAuthentication).Get("/settings", api.Settings)
r.With(api.auth.accessControl).Mount("/github", NewGitHubGateway())
r.With(api.auth.accessControl).Mount("/gitlab", NewGitLabGateway())
r.With(api.auth.accessControl).Mount("/bitbucket", NewBitBucketGateway())
r.With(api.auth.accessControl).Get("/settings", api.Settings)
})

if globalConfig.MultiInstanceMode {
Expand Down
179 changes: 170 additions & 9 deletions api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,77 @@ import (

jwt "github.com/dgrijalva/jwt-go"
"github.com/sirupsen/logrus"
"github.com/okta/okta-jwt-verifier-golang"
)

// requireAuthentication checks incoming requests for tokens presented using the Authorization header
func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) {
logrus.Info("Getting auth token")
token, err := a.extractBearerToken(w, r)
type Authenticator interface {
// `authenticate` checks incoming requests for tokens presented using the Authorization header
authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error)
getName() string
}

type Authorizer interface {
// `authorize` checks incoming requests for roles data in tokens that is parsed and verified by a prior `authenticate` step
authorize(w http.ResponseWriter, r *http.Request) (context.Context, error)
getName() string
}

type Auth struct {
authenticator Authenticator
authorizer Authorizer
version string
}

type JWTAuthenticator struct {
name string
auth Auth
}

type OktaJWTAuthenticator struct {
name string
auth Auth
}

type RolesAuthorizer struct {
name string
auth Auth
}

func NewAuthWithVersion(ctx context.Context, version string) *Auth {
config := getConfig(ctx)
auth := &Auth{version: version}
authenticatorName := config.JWT.Authenticator

if (authenticatorName == "bearer-jwt-token") {
auth.authenticator = &JWTAuthenticator{name: "bearer-jwt-token", auth: *auth}
} else if (authenticatorName == "bearer-okta-jwt-token") {
auth.authenticator = &OktaJWTAuthenticator{name: "bearer-okta-jwt-token", auth: *auth}
} else {
if (authenticatorName != "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to keep backwards compatibility, this initialization should not fail. The default behavior when there is no authenticationName should be to use bearer jwt tokens.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@calavera good catch. I didn't mean to break backward compatibility.

I will address the request soon. Hopefully within this week.

Thank for your time and looking over the PR!

logrus.Fatal("Authenticator `" + authenticatorName + "` is not recognized")
} else {
logrus.Fatal("Authenticator is not defined")
}
}

auth.authorizer = &RolesAuthorizer{name: "bearer-jwt-token-roles", auth: *auth}

return auth
}

// check both authentication and authorization
func (a *Auth) accessControl(w http.ResponseWriter, r *http.Request) (context.Context, error) {
logrus.Infof("Authenticate with: %v", a.authenticator.getName())
ctx, err := a.authenticator.authenticate(w, r)
if err != nil {
return nil, err
}

logrus.Infof("Parsing JWT claims: %v", token)
return a.parseJWTClaims(token, r)
logrus.Infof("Authorizing with: %v", a.authorizer.getName())
return a.authorizer.authorize(w, r.WithContext(ctx))
}

func (a *API) extractBearerToken(w http.ResponseWriter, r *http.Request) (string, error) {
func (a *Auth) extractBearerToken(w http.ResponseWriter, r *http.Request) (string, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return "", unauthorizedError("This endpoint requires a Bearer token")
Expand All @@ -34,15 +90,120 @@ func (a *API) extractBearerToken(w http.ResponseWriter, r *http.Request) (string
return matches[1], nil
}

func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, error) {
func (a *JWTAuthenticator) getName() string {
return a.name
}

func (a *JWTAuthenticator) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
logrus.Info("Getting auth token")
token, err := a.auth.extractBearerToken(w, r)
if err != nil {
return nil, err
}

logrus.Infof("Parsing JWT claims: %v", token)
return a.parseJWTClaims(token, r)
}

func (a *JWTAuthenticator) parseJWTClaims(bearer string, r *http.Request) (context.Context, error) {
config := getConfig(r.Context())
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
token, err := p.ParseWithClaims(bearer, &GatewayClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(config.JWT.Secret), nil
})

if err != nil {
return nil, unauthorizedError("Invalid token: %v", err)
}
claims := token.Claims.(GatewayClaims)
return withClaims(r.Context(), &claims), nil
}

func (a *OktaJWTAuthenticator) getName() string {
return a.name
}

func (a *OktaJWTAuthenticator) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
logrus.Info("Getting auth token")
token, err := a.auth.extractBearerToken(w, r)
if err != nil {
return nil, err
}

logrus.Infof("Parsing JWT claims: %v", token)
return a.parseOktaJWTClaims(token, r)
}

func (a *OktaJWTAuthenticator) parseOktaJWTClaims(bearer string, r *http.Request) (context.Context, error) {
config := getConfig(r.Context())

toValidate := map[string]string{}
toValidate["aud"] = config.JWT.AUD
toValidate["cid"] = config.JWT.CID

jwtVerifierSetup := jwtverifier.JwtVerifier{
Issuer: config.JWT.Issuer,
ClaimsToValidate: toValidate,
}

verifier := jwtVerifierSetup.New()

token, err := verifier.VerifyAccessToken(bearer)

if err != nil {
return nil, unauthorizedError("Invalid token: %v", err)
}
logrus.Infof("parseJWTClaims passed")

claims := GatewayClaims{
Email: token.Claims["sub"].(string),
AppMetaData: nil,
UserMetaData: nil,
StandardClaims: jwt.StandardClaims{
Audience: token.Claims["aud"].(string),
ExpiresAt: int64(token.Claims["exp"].(float64)),
Id: token.Claims["jti"].(string),
IssuedAt: int64(token.Claims["iat"].(float64)),
Issuer: token.Claims["iss"].(string),
NotBefore: 0,
Subject: token.Claims["sub"].(string),
},
}

return withClaims(r.Context(), &claims), nil
}

func (a *RolesAuthorizer) getName() string {
return a.name
}

func (a *RolesAuthorizer) authorize(w http.ResponseWriter, r *http.Request) (context.Context, error) {
ctx := r.Context()
claims := getClaims(ctx)
config := getConfig(ctx)

logrus.Infof("authenticate url: %v+", r.URL)
logrus.Infof("claims: %v+", claims)
if claims == nil {
return nil, unauthorizedError("Access to endpoint not allowed: no claims found in Bearer token")
}

if len(config.Roles) == 0 {
return ctx, nil
}

roles, ok := claims.AppMetaData["roles"]
if ok {
roleStrings, _ := roles.([]interface{})
for _, data := range roleStrings {
role, _ := data.(string)
for _, adminRole := range config.Roles {
if role == adminRole {
return ctx, nil
}
}
}
}

return withToken(r.Context(), token), nil
return nil, unauthorizedError("Access to endpoint not allowed: your role doesn't allow access")
}
38 changes: 2 additions & 36 deletions api/bitbucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"io/ioutil"
"net/http"
Expand Down Expand Up @@ -118,8 +117,8 @@ func (bb *BitBucketGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if err := bb.authenticate(w, r); err != nil {
handleError(unauthorizedError(err.Error()), w, r)
if !bitbucketAllowedRegexp.MatchString(r.URL.Path) {
handleError(unauthorizedError("Access to endpoint not allowed: this part of BitBucket's API has been restricted"), w, r)
return
}

Expand All @@ -142,39 +141,6 @@ func (bb *BitBucketGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
bb.proxy.ServeHTTP(w, r.WithContext(ctx))
}

func (bb *BitBucketGateway) authenticate(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims := getClaims(ctx)
config := getConfig(ctx)

if claims == nil {
return errors.New("Access to endpoint not allowed: no claims found in Bearer token")
}

if !bitbucketAllowedRegexp.MatchString(r.URL.Path) {
return errors.New("Access to endpoint not allowed: this part of BitBucket's API has been restricted")
}

if len(config.Roles) == 0 {
return nil
}

roles, ok := claims.AppMetaData["roles"]
if ok {
roleStrings, _ := roles.([]interface{})
for _, data := range roleStrings {
role, _ := data.(string)
for _, adminRole := range config.Roles {
if role == adminRole {
return nil
}
}
}
}

return errors.New("Access to endpoint not allowed: your role doesn't allow access")
}

func rewriteBitBucketLink(link, endpointAPIURL, proxyAPIURL string) string {
return proxyAPIURL + strings.TrimPrefix(link, endpointAPIURL)
}
Expand Down
Loading