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

feat: support for clerk auth #471

Merged
merged 5 commits into from
Aug 7, 2023
Merged
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
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)
yashmehrotra marked this conversation as resolved.
Show resolved Hide resolved
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)
yashmehrotra marked this conversation as resolved.
Show resolved Hide resolved
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
Loading