Skip to content

Commit

Permalink
feat: support for clerk auth (#471)
Browse files Browse the repository at this point in the history
* feat: support for clerk auth

* chore: use auth modes for enabling provider

* chore: use session id for cache instead of token

* chore: error handling for jwt failure

* chore: add support for cookies in auth middleware
  • Loading branch information
yashmehrotra authored Aug 7, 2023
1 parent c60a5fe commit e26537a
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 54 deletions.
170 changes: 170 additions & 0 deletions auth/clerk_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package auth

import (
"fmt"
"net/http"
"strings"
"time"

"github.com/clerkinc/clerk-sdk-go/clerk"
"github.com/flanksource/commons/logger"
"github.com/flanksource/incident-commander/api"
"github.com/flanksource/incident-commander/db"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/patrickmn/go-cache"
)

const (
clerkSessionCookie = "_session"
)

type ClerkHandler struct {
client clerk.Client
dbJwtSecret string
tokenCache *cache.Cache
userCache *cache.Cache
}

func NewClerkHandler(secretKey, dbJwtSecret string) (*ClerkHandler, error) {
client, err := clerk.NewClient(secretKey)
if err != nil {
return nil, err
}

return &ClerkHandler{
client: client,
dbJwtSecret: dbJwtSecret,
tokenCache: cache.New(3*24*time.Hour, 12*time.Hour),
userCache: cache.New(3*24*time.Hour, 12*time.Hour),
}, nil
}

func (h ClerkHandler) Session(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if canSkipAuth(c) {
return next(c)
}

// Extract session token from Authorization header
sessionToken := c.Request().Header.Get(echo.HeaderAuthorization)
sessionToken = strings.TrimPrefix(sessionToken, "Bearer ")
if sessionToken == "" {
// Check for `_session` cookie
sessionTokenCookie, err := c.Request().Cookie(clerkSessionCookie)
if err != nil {
// Cookie not found
return c.String(http.StatusUnauthorized, "Unauthorized")
}
sessionToken = sessionTokenCookie.Value
}

user, sessID, err := h.getUser(sessionToken)
if err != nil {
logger.Errorf("Error fetching user from clerk: %v", err)
return c.String(http.StatusUnauthorized, "Unauthorized")
}

ctx := c.(*api.Context)
if user.ExternalID == nil {
user, err = h.createDBUser(ctx, user)
if err != nil {
logger.Errorf("Error creating user in database from clerk: %v", err)
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Clear user from cache so that new metadata is used
h.userCache.Delete(sessID)
}

token, err := h.getDBToken(sessID, *user.ExternalID)
if err != nil {
logger.Errorf("Error generating JWT Token: %v", err)
return c.String(http.StatusUnauthorized, "Unauthorized")
}

c.Request().Header.Add(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", token))
c.Request().Header.Add(UserIDHeaderKey, *user.ExternalID)
return next(c)
}
}

func (h *ClerkHandler) generateDBToken(id string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"role": DefaultPostgrestRole,
"id": id,
})
return token.SignedString([]byte(h.dbJwtSecret))
}

func (h *ClerkHandler) getDBToken(sessionID, userID string) (string, error) {
cacheKey := sessionID + userID
if token, exists := h.tokenCache.Get(cacheKey); exists {
return token.(string), nil
}
// Adding Authorization Token for PostgREST
token, err := h.generateDBToken(userID)
if err != nil {
return "", err
}
h.tokenCache.SetDefault(cacheKey, token)
return token, nil
}

func (h *ClerkHandler) getUser(sessionToken string) (*clerk.User, string, error) {
sessClaims, err := h.client.VerifyToken(sessionToken)
if err != nil {
return nil, "", err
}

cacheKey := sessClaims.SessionID
if user, exists := h.userCache.Get(cacheKey); exists {
return user.(*clerk.User), sessClaims.SessionID, nil
}

user, err := h.client.Users().Read(sessClaims.Claims.Subject)
if err != nil {
return nil, "", err
}
h.userCache.SetDefault(cacheKey, user)
return user, sessClaims.SessionID, nil
}

func (h *ClerkHandler) createDBUser(ctx *api.Context, user *clerk.User) (*clerk.User, error) {
if user.ExternalID != nil {
return user, nil
}
if user.PrimaryEmailAddressID == nil {
return nil, fmt.Errorf("clerk.user[%s] has no primary email", user.ID)
}

var email string
for _, addr := range user.EmailAddresses {
if addr.ID == *user.PrimaryEmailAddressID {
email = addr.EmailAddress
break
}
}

var name []string
if user.FirstName != nil {
name = append(name, *user.FirstName)
}
if user.LastName != nil {
name = append(name, *user.LastName)
}
person := api.Person{
Name: strings.Join(name, " "),
Email: email,
}

dbUser, err := db.GetOrCreateUser(ctx, person)
if err != nil {
return nil, err
}

id := dbUser.ID.String()
userUpdateParams := clerk.UpdateUser{
ExternalID: &id,
}
return h.client.Users().Update(user.ID, &userUpdateParams)
}
33 changes: 0 additions & 33 deletions auth/client.go

This file was deleted.

28 changes: 28 additions & 0 deletions auth/users.go → auth/kratos_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ import (
client "github.com/ory/client-go"
)

type KratosHandler struct {
client *client.APIClient
adminClient *client.APIClient
jwtSecret string
}

func NewKratosHandler(kratosAPI, kratosAdminAPI, jwtSecret string) *KratosHandler {
return &KratosHandler{
client: newAPIClient(kratosAPI),
adminClient: newAdminAPIClient(kratosAdminAPI),
jwtSecret: jwtSecret,
}
}

func newAPIClient(kratosAPI string) *client.APIClient {
return newKratosClient(kratosAPI)
}

func newAdminAPIClient(kratosAdminAPI string) *client.APIClient {
return newKratosClient(kratosAdminAPI)
}

func newKratosClient(apiURL string) *client.APIClient {
configuration := client.NewConfiguration()
configuration.Servers = []client.ServerConfiguration{{URL: apiURL}}
return client.NewAPIClient(configuration)
}

const (
AdminName = "Admin"
AdminEmail = "admin@local"
Expand Down
1 change: 1 addition & 0 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (k *kratosMiddleware) Session(next echo.HandlerFunc) echo.HandlerFunc {
token, err := k.getDBToken(session.Id, session.Identity.GetId())
if err != nil {
logger.Errorf("Error generating JWT Token: %v", err)
return c.String(http.StatusUnauthorized, "Unauthorized")
}
c.Request().Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
c.Request().Header.Add(UserIDHeaderKey, session.Identity.GetId())
Expand Down
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ var Root = &cobra.Command{
var dev bool
var httpPort, metricsPort, devGuiPort int
var publicEndpoint = "http://localhost:8080"
var configDb, kratosAPI, kratosAdminAPI, postgrestURI string
var enableAuth, disablePostgrest bool
var configDb, authMode, kratosAPI, kratosAdminAPI, postgrestURI, clerkSecret string
var disablePostgrest bool

func ServerFlags(flags *pflag.FlagSet) {
flags.IntVar(&httpPort, "httpPort", 8080, "Port to expose a health dashboard")
Expand All @@ -52,8 +52,9 @@ func ServerFlags(flags *pflag.FlagSet) {
flags.StringVar(&configDb, "config-db", "http://config-db:8080", "Config DB URL")
flags.StringVar(&kratosAPI, "kratos-api", "http://kratos-public:80", "Kratos API service")
flags.StringVar(&kratosAdminAPI, "kratos-admin", "http://kratos-admin:80", "Kratos Admin API service")
flags.StringVar(&clerkSecret, "clerk-secret", "", "Clerk Secret Key")
flags.StringVar(&postgrestURI, "postgrest-uri", "http://localhost:3000", "URL for the PostgREST instance to use. If localhost is supplied, a PostgREST instance will be started")
flags.BoolVar(&enableAuth, "enable-auth", false, "Enable authentication via Kratos")
flags.StringVar(&authMode, "auth", "", "Enable authentication via Kratos or Clerk. Valid values are [kratos, clerk]")
flags.DurationVar(&rules.Period, "rules-period", 5*time.Minute, "Period to run the rules")
flags.BoolVar(&disablePostgrest, "disable-postgrest", false, "Disable PostgREST. Deprecated (Use --postgrest-uri '' to disable PostgREST)")
flags.StringVar(&mail.FromAddress, "email-from-address", "no-reply@flanksource.com", "Email address of the sender")
Expand Down
43 changes: 30 additions & 13 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,37 @@ func createHTTPServer(gormDB *gorm.DB) *echo.Echo {
e.Use(echoprometheus.NewMiddleware("mission_control"))
e.GET("/metrics", echoprometheus.NewHandler())

kratosHandler := auth.NewKratosHandler(kratosAPI, kratosAdminAPI, db.PostgRESTJWTSecret)
if enableAuth {
adminUserID, err := kratosHandler.CreateAdminUser(context.Background())
if err != nil {
logger.Fatalf("Failed to created admin user: %v", err)
}
if authMode != "" {
var (
adminUserID string
err error
)

switch authMode {
case "kratos":
kratosHandler := auth.NewKratosHandler(kratosAPI, kratosAdminAPI, db.PostgRESTJWTSecret)
adminUserID, err = kratosHandler.CreateAdminUser(context.Background())
if err != nil {
logger.Fatalf("Failed to created admin user: %v", err)
}

middleware, err := kratosHandler.KratosMiddleware()
if err != nil {
logger.Fatalf("Failed to initialize kratos middleware: %v", err)
}
e.Use(middleware.Session)
e.POST("/auth/invite_user", kratosHandler.InviteUser, rbac.Authorization(rbac.ObjectAuth, rbac.ActionWrite))

middleware, err := kratosHandler.KratosMiddleware()
if err != nil {
logger.Fatalf("Failed to initialize kratos middleware: %v", err)
case "clerk":
clerkHandler, err := auth.NewClerkHandler(clerkSecret, db.PostgRESTJWTSecret)
if err != nil {
logger.Fatalf("Failed to initialize clerk client: %v", err)
}
e.Use(clerkHandler.Session)

default:
logger.Fatalf("Invalid auth provider: %s", authMode)
}
e.Use(middleware.Session)

// Initiate RBAC
if err := rbac.Init(adminUserID); err != nil {
Expand All @@ -94,8 +113,6 @@ func createHTTPServer(gormDB *gorm.DB) *echo.Echo {
return c.JSON(http.StatusOK, api.HTTPSuccess{Message: "ok"})
})

e.POST("/auth/invite_user", kratosHandler.InviteUser, rbac.Authorization(rbac.ObjectAuth, rbac.ActionWrite))

e.GET("/snapshot/topology/:id", snapshot.Topology)
e.GET("/snapshot/incident/:id", snapshot.Incident)
e.GET("/snapshot/config/:id", snapshot.Config)
Expand Down Expand Up @@ -167,7 +184,7 @@ var Serve = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
// PostgREST needs to know how it is exposed to create the correct links
db.HttpEndpoint = publicEndpoint + "/db"
if !enableAuth {
if authMode != "" {
db.PostgresDBAnonRole = "postgrest_api"
}

Expand Down
12 changes: 12 additions & 0 deletions db/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package db
import (
"github.com/flanksource/incident-commander/api"
"github.com/flanksource/incident-commander/utils"
"github.com/google/uuid"
)

func UpdateUserProperties(ctx *api.Context, userID string, newProps api.PersonProperties) error {
Expand All @@ -22,3 +23,14 @@ func UpdateUserProperties(ctx *api.Context, userID string, newProps api.PersonPr
func UpdateIdentityState(ctx *api.Context, id, state string) error {
return ctx.DB().Table("identities").Where("id = ?", id).Update("state", state).Error
}

func GetOrCreateUser(ctx *api.Context, user api.Person) (api.Person, error) {
if err := ctx.DB().Table("people").Where("email = ?", user.Email).Find(&user).Error; err != nil {
return api.Person{}, err
}
if user.ID != uuid.Nil {
return user, nil
}
err := ctx.DB().Table("people").Create(&user).Error
return user, err
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/andygrunwald/go-jira v1.16.0
github.com/casbin/casbin/v2 v2.73.0
github.com/casbin/gorm-adapter/v3 v3.18.0
github.com/clerkinc/clerk-sdk-go v1.47.0
github.com/containrrr/shoutrrr v0.7.1
github.com/fergusstrange/embedded-postgres v1.23.0
github.com/flanksource/commons v1.10.2
Expand Down Expand Up @@ -57,6 +58,7 @@ require (
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.9.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/go-openapi/jsonpointer v0.20.0 // indirect
Expand Down
Loading

0 comments on commit e26537a

Please sign in to comment.