diff --git a/api/auth.go b/api/auth.go index 9d163aa..c4d2535 100644 --- a/api/auth.go +++ b/api/auth.go @@ -4,30 +4,88 @@ import ( "context" "net/http" + jwt "github.com/dgrijalva/jwt-go" "github.com/netlify/git-gateway/conf" "github.com/sirupsen/logrus" "github.com/okta/okta-jwt-verifier-golang" ) +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 prior authentication step + authorize(w http.ResponseWriter, r *http.Request) (context.Context, error) + getName() string +} + type Auth struct { config *conf.GlobalConfiguration + 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, globalConfig *conf.GlobalConfiguration, version string) *Auth { + auth := &Auth{config: globalConfig, version: version} + + auth.authenticator = &OktaJWTAuthenticator{name: "bearer-jwt-token", auth: *auth} + 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) { - _, err := a.authenticate(w, r) + logrus.Infof("Authenticate with: %v", a.authenticator.getName()) + ctx, err := a.authenticator.authenticate(w, r) if err != nil { return nil, err } - return a.authorize(w, r) + logrus.Infof("Authorizing with: %v", a.authorizer.getName()) + return a.authorizer.authorize(w, r.WithContext(ctx)) +} + +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") + } + + matches := bearerRegexp.FindStringSubmatch(authHeader) + if len(matches) != 2 { + return "", unauthorizedError("This endpoint requires a Bearer token") + } + + return matches[1], nil +} + +func (a *JWTAuthenticator) getName() string { + return a.name } -// authenticate checks incoming requests for tokens presented using the Authorization header -func (a *Auth) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { +func (a *JWTAuthenticator) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { logrus.Info("Getting auth token") - token, err := a.extractBearerToken(w, r) + token, err := a.auth.extractBearerToken(w, r) if err != nil { return nil, err } @@ -36,61 +94,36 @@ func (a *Auth) authenticate(w http.ResponseWriter, r *http.Request) (context.Con return a.parseJWTClaims(token, r) } -// authorize checks incoming requests for roles data in tokens that is parsed and verified by prior authentication step -func (a *Auth) 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) - 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 - } +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 + }) - 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 - } - } - } + if err != nil { + return nil, unauthorizedError("Invalid token: %v", err) } - - return nil, unauthorizedError("Access to endpoint not allowed: your role doesn't allow access") + claims := token.Claims.(GatewayClaims) + return withClaims(r.Context(), &claims), nil } -func NewAuthWithVersion(ctx context.Context, globalConfig *conf.GlobalConfiguration, version string) *Auth { - auth := &Auth{config: globalConfig, version: version} - - return auth +func (a *OktaJWTAuthenticator) getName() string { + return a.name } -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") - } - - matches := bearerRegexp.FindStringSubmatch(authHeader) - if len(matches) != 2 { - return "", unauthorizedError("This endpoint requires a Bearer token") +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 } - return matches[1], nil + logrus.Infof("Parsing JWT claims: %v", token) + return a.parseOktaJWTClaims(token, r) } -func (a *Auth) parseJWTClaims(bearer string, r *http.Request) (context.Context, error) { - // Reimplemented to use Okta lib - // Original validation only work for HS256 algo, - // Okta supports RS256 only which requires public key downloading and caching (key rotation) +func (a *OktaJWTAuthenticator) parseOktaJWTClaims(bearer string, r *http.Request) (context.Context, error) { config := getConfig(r.Context()) toValidate := map[string]string{} @@ -106,17 +139,47 @@ func (a *Auth) parseJWTClaims(bearer string, r *http.Request) (context.Context, _, err := verifier.VerifyAccessToken(bearer) - // @TODO? WARNING: Should be roles and other claims be checked here? - if err != nil { return nil, unauthorizedError("Invalid token: %v", err) } + claims := GatewayClaims{Email: "e", StandardClaims: jwt.StandardClaims{Audience: "a"}} + logrus.Infof("parseJWTClaims passed") + return withClaims(r.Context(), &claims), nil +} - // return nil, because the `github.go` is coded to send personal token - // both github oauth generates its own id, so oauth pass-thru is impossible - // we can improve the gateway to talk oauth with github.com, but we will - // still return nil here. - return nil, 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 nil, unauthorizedError("Access to endpoint not allowed: your role doesn't allow access") } diff --git a/api/context.go b/api/context.go index 583d825..ca0abed 100644 --- a/api/context.go +++ b/api/context.go @@ -4,7 +4,6 @@ import ( "context" "net/url" - jwt "github.com/dgrijalva/jwt-go" "github.com/netlify/git-gateway/conf" "github.com/netlify/git-gateway/models" ) @@ -20,8 +19,8 @@ func (c contextKey) String() string { } const ( - accessTokenKey = contextKey("token") - tokenKey = contextKey("jwt") + accessTokenKey = contextKey("access_token") + tokenClaimsKey = contextKey("jwt_claims") requestIDKey = contextKey("request_id") configKey = contextKey("config") instanceIDKey = contextKey("instance_id") @@ -31,27 +30,17 @@ const ( netlifyIDKey = contextKey("netlify_id") ) -// withToken adds the JWT token to the context. -func withToken(ctx context.Context, token *jwt.Token) context.Context { - return context.WithValue(ctx, tokenKey, token) -} - -// getToken reads the JWT token from the context. -func getToken(ctx context.Context) *jwt.Token { - obj := ctx.Value(tokenKey) - if obj == nil { - return nil - } - - return obj.(*jwt.Token) +// withTokenClaims adds the JWT token claims to the context. +func withClaims(ctx context.Context, claims *GatewayClaims) context.Context { + return context.WithValue(ctx, tokenClaimsKey, claims) } func getClaims(ctx context.Context) *GatewayClaims { - token := getToken(ctx) - if token == nil { + claims := ctx.Value(tokenClaimsKey) + if claims == nil { return nil } - return token.Claims.(*GatewayClaims) + return claims.(*GatewayClaims) } func withRequestID(ctx context.Context, id string) context.Context { diff --git a/api/github.go b/api/github.go index a8925b0..47cedb5 100644 --- a/api/github.go +++ b/api/github.go @@ -74,8 +74,6 @@ func (gh *GitHubGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx = withProxyTarget(ctx, target) ctx = withAccessToken(ctx, config.GitHub.AccessToken) - log := getLogEntry(r) - log.Infof("proxy.ServeHTTP: %+v\n", r.WithContext(ctx)) gh.proxy.ServeHTTP(w, r.WithContext(ctx)) }